From 675790852a6a4a214063457a555b83f350d8aed6 Mon Sep 17 00:00:00 2001 From: rabble Date: Mon, 25 May 2026 14:07:21 +1200 Subject: [PATCH 1/3] fix(feed): retry web playback after age verification Web auth-gated fullscreen videos were left on the age-restricted overlay after verification because the HLS player was never recreated. Retry the active web player once auth succeeds so playback can load with fresh viewer headers. Co-Authored-By: Claude Opus 4.7 --- .../pooled_fullscreen_video_feed_screen.dart | 25 +++++++++++++-- mobile/lib/widgets/web_video_feed.dart | 25 +++++++++++++-- ...led_fullscreen_video_feed_screen_test.dart | 3 ++ mobile/test/widgets/web_video_feed_test.dart | 31 +++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart b/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart index c7e99d88c7..3b40114c4c 100644 --- a/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart +++ b/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart @@ -959,7 +959,9 @@ class _FullscreenFeedContentState extends ConsumerState }) { return _WebFullscreenItem( video: video, + index: index, isActive: isActive, + feedKey: _webFeedKey, isOwnVideo: currentUserPubkey == video.pubkey, @@ -1158,7 +1160,9 @@ class _PooledFullscreenItem extends ConsumerWidget { class _WebFullscreenItem extends ConsumerWidget { const _WebFullscreenItem({ required this.video, + required this.index, required this.isActive, + required this.feedKey, required this.isOwnVideo, this.controller, this.contextTitle, @@ -1168,7 +1172,9 @@ class _WebFullscreenItem extends ConsumerWidget { }); final VideoEvent video; + final int index; final bool isActive; + final GlobalKey feedKey; final bool isOwnVideo; final VideoPlayerController? controller; final String? contextTitle; @@ -1287,7 +1293,11 @@ class _WebFullscreenItem extends ConsumerWidget { ), ), ), - _WebFullscreenLoadingModerationOverlay(video: video), + _WebFullscreenLoadingModerationOverlay( + video: video, + index: index, + feedKey: feedKey, + ), ], ), ), @@ -1296,9 +1306,15 @@ class _WebFullscreenItem extends ConsumerWidget { } class _WebFullscreenLoadingModerationOverlay extends ConsumerStatefulWidget { - const _WebFullscreenLoadingModerationOverlay({required this.video}); + const _WebFullscreenLoadingModerationOverlay({ + required this.video, + required this.index, + required this.feedKey, + }); final VideoEvent video; + final int index; + final GlobalKey feedKey; @override ConsumerState<_WebFullscreenLoadingModerationOverlay> createState() => @@ -1327,6 +1343,11 @@ class _WebFullscreenLoadingModerationOverlayState ); if (headers == null || !mounted) return; + context.read().report( + widget.video.id, + PlaybackStatus.ready, + ); + widget.feedKey.currentState?.retryPlayback(widget.index); setState(() { _dismissedAfterVerify = true; }); diff --git a/mobile/lib/widgets/web_video_feed.dart b/mobile/lib/widgets/web_video_feed.dart index 823df2b3fc..fd86302860 100644 --- a/mobile/lib/widgets/web_video_feed.dart +++ b/mobile/lib/widgets/web_video_feed.dart @@ -231,8 +231,13 @@ class WebVideoFeedState extends State { /// framework's locked unmount tick. Mutating `_controllers.value` /// synchronously would re-enter sibling `ValueListenableBuilder`s and /// trip "setState() called when widget tree was locked". - void _onPlayerDisposed(int index) { - _playerKeys.remove(index); + void _onPlayerDisposed( + int index, + GlobalKey disposedKey, + ) { + if (identical(_playerKeys[index], disposedKey)) { + _playerKeys.remove(index); + } final current = _controllers.value; final controller = current[index]; if (controller == null) return; @@ -244,6 +249,20 @@ class WebVideoFeedState extends State { }); } + void retryPlayback(int index) { + if (!mounted || index < 0 || index >= widget.videos.length) return; + + final controller = _controllers.value[index]; + if (controller != null) { + _detachCompletionListener(index, controller); + _controllers.value = Map.of( + _controllers.value, + )..remove(index); + } + _playerKeys.remove(index); + setState(() {}); + } + Future animateToPage(int index) async { if (!mounted || widget.videos.isEmpty) return; @@ -392,7 +411,7 @@ class WebVideoFeedState extends State { }, onDisposed: () { if (!mounted) return; - _onPlayerDisposed(index); + _onPlayerDisposed(index, playerKey); }, onError: () { if (!mounted) return; diff --git a/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_test.dart b/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_test.dart index a68a45246e..f846157f87 100644 --- a/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_test.dart +++ b/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_test.dart @@ -35,6 +35,7 @@ import 'package:openvine/widgets/video_feed_item/feed_videos.dart'; import 'package:openvine/widgets/video_feed_item/moderated_content_overlay.dart'; import 'package:openvine/widgets/video_feed_item/video_feed_item.dart'; import 'package:openvine/widgets/web_video_feed.dart'; +import 'package:openvine/widgets/web_video_player.dart'; import 'package:pooled_video_player/pooled_video_player.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -313,6 +314,7 @@ void main() { FullscreenFeedState? state, List? additionalOverrides, VideoFeedControllerFactory? controllerFactory, + WebVideoPlayerControllerFactory? webControllerFactory, String? contextTitle, }) { final effectiveState = state ?? const FullscreenFeedState(); @@ -344,6 +346,7 @@ void main() { controllerFactory: controllerFactory ?? ((videos, initialIndex) => defaultController), + webControllerFactory: webControllerFactory, ), ), ); diff --git a/mobile/test/widgets/web_video_feed_test.dart b/mobile/test/widgets/web_video_feed_test.dart index 07b4bc31db..bf965d2b82 100644 --- a/mobile/test/widgets/web_video_feed_test.dart +++ b/mobile/test/widgets/web_video_feed_test.dart @@ -276,6 +276,37 @@ void main() { expect(activeIndex, 1); }); + testWidgets('retryPlayback recreates the player for an index', ( + tester, + ) async { + final key = GlobalKey(); + final createdControllers = <_FakeVideoPlayerController>[]; + + await tester.pumpWidget( + MaterialApp( + home: WebVideoFeed( + key: key, + videos: [_makeVideo()], + controllerFactory: ({required url, required headers}) { + final controller = _FakeVideoPlayerController(); + createdControllers.add(controller); + return controller; + }, + ), + ), + ); + await tester.pump(); + + expect(createdControllers, hasLength(1)); + + key.currentState!.retryPlayback(0); + await tester.pump(); + await tester.pump(); + + expect(createdControllers, hasLength(2)); + expect(key.currentState!.debugPlayerKeyCount, 1); + }); + testWidgets( 'drops controller entries when WebVideoPlayer items are disposed', (tester) async { From 4a0854470d8c632c0b375e64370e3e732154e9f0 Mon Sep 17 00:00:00 2001 From: rabble Date: Mon, 25 May 2026 14:59:06 +1200 Subject: [PATCH 2/3] fix(feed): preserve web age verification headers Age verification returns request headers that must be used when recreating the web player; otherwise the retry immediately hits the age gate again. Co-Authored-By: Claude Opus 4.7 --- .../pooled_fullscreen_video_feed_screen.dart | 2 +- mobile/lib/widgets/web_video_feed.dart | 10 ++++-- ...fullscreen_video_feed_screen_web_test.dart | 14 ++++++-- mobile/test/widgets/web_video_feed_test.dart | 33 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart b/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart index 3b40114c4c..d273a436fb 100644 --- a/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart +++ b/mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart @@ -1347,7 +1347,7 @@ class _WebFullscreenLoadingModerationOverlayState widget.video.id, PlaybackStatus.ready, ); - widget.feedKey.currentState?.retryPlayback(widget.index); + widget.feedKey.currentState?.retryPlayback(widget.index, headers: headers); setState(() { _dismissedAfterVerify = true; }); diff --git a/mobile/lib/widgets/web_video_feed.dart b/mobile/lib/widgets/web_video_feed.dart index fd86302860..4b7c9f7a23 100644 --- a/mobile/lib/widgets/web_video_feed.dart +++ b/mobile/lib/widgets/web_video_feed.dart @@ -162,6 +162,7 @@ class WebVideoFeedState extends State { final Map _controllerListeners = {}; final Map _lastPositions = {}; final Map _armedForCompletion = {}; + final Map> _requestHeadersByVideoId = {}; int get currentIndex => _currentIndex; int get videoCount => widget.videos.length; @@ -203,6 +204,8 @@ class WebVideoFeedState extends State { void _pruneStaleEntries() { final validCount = widget.videos.length; _playerKeys.removeWhere((index, _) => index >= validCount); + final validIds = widget.videos.map((video) => video.id).toSet(); + _requestHeadersByVideoId.removeWhere((id, _) => !validIds.contains(id)); final current = _controllers.value; final pruned = { for (final entry in current.entries) @@ -249,9 +252,12 @@ class WebVideoFeedState extends State { }); } - void retryPlayback(int index) { + void retryPlayback(int index, {Map? headers}) { if (!mounted || index < 0 || index >= widget.videos.length) return; + if (headers != null) { + _requestHeadersByVideoId[widget.videos[index].id] = headers; + } final controller = _controllers.value[index]; if (controller != null) { _detachCompletionListener(index, controller); @@ -394,7 +400,7 @@ class WebVideoFeedState extends State { isActive: isActive, videoUrl: playbackUrl, playerKey: playerKey, - headers: widget.headers, + headers: _requestHeadersByVideoId[video.id] ?? widget.headers, controllerFactory: widget.controllerFactory, controllersListenable: _controllers, itemBuilder: widget.itemBuilder, diff --git a/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_web_test.dart b/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_web_test.dart index 6e87500100..1071cd1d01 100644 --- a/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_web_test.dart +++ b/mobile/test/screens/feed/pooled_fullscreen_video_feed_screen_web_test.dart @@ -243,6 +243,7 @@ void main() { ); final moderationService = _MockVideoModerationStatusService(); final mediaAuthInterceptor = _FakeMediaAuthInterceptor(); + final requestHeaders = >[]; when(() => mockBloc.state).thenReturn(state); when(() => moderationService.fetchStatus(sha256)).thenAnswer( (_) async => const VideoModerationStatus( @@ -276,8 +277,10 @@ void main() { ), ], child: FullscreenFeedContent( - webControllerFactory: ({required url, required headers}) => - webController, + webControllerFactory: ({required url, required headers}) { + requestHeaders.add(headers); + return FakeVideoPlayerController(); + }, ), ), ), @@ -293,6 +296,13 @@ void main() { await tester.pump(); expect(mediaAuthInterceptor.didHandleUnauthorizedMedia, isTrue); + expect( + requestHeaders, + equals([ + const {}, + const {'Authorization': 'Bearer test'}, + ]), + ); expect(find.text(l10n.videoErrorAgeRestricted), findsNothing); }, skip: !kIsWeb, diff --git a/mobile/test/widgets/web_video_feed_test.dart b/mobile/test/widgets/web_video_feed_test.dart index bf965d2b82..7e4e4f4eed 100644 --- a/mobile/test/widgets/web_video_feed_test.dart +++ b/mobile/test/widgets/web_video_feed_test.dart @@ -307,6 +307,39 @@ void main() { expect(key.currentState!.debugPlayerKeyCount, 1); }); + testWidgets('retryPlayback applies verified headers to recreated player', ( + tester, + ) async { + final key = GlobalKey(); + final requestHeaders = >[]; + + await tester.pumpWidget( + MaterialApp( + home: WebVideoFeed( + key: key, + videos: [_makeVideo()], + controllerFactory: ({required url, required headers}) { + requestHeaders.add(headers); + return _FakeVideoPlayerController(); + }, + ), + ), + ); + await tester.pump(); + + expect(requestHeaders, equals([const {}])); + + const verifiedHeaders = {'Authorization': 'Bearer verified'}; + key.currentState!.retryPlayback(0, headers: verifiedHeaders); + await tester.pump(); + await tester.pump(); + + expect( + requestHeaders, + equals([const {}, verifiedHeaders]), + ); + }); + testWidgets( 'drops controller entries when WebVideoPlayer items are disposed', (tester) async { From 98de0c325840ad4e1d85b87cd13e42d114739bc1 Mon Sep 17 00:00:00 2001 From: rabble Date: Mon, 25 May 2026 16:08:58 +1200 Subject: [PATCH 3/3] fix(feed): enable web auth player by default Age-gated media on web needs the HLS auth player path because direct browser MP4 playback cannot attach NIP-98 authorization headers. Co-Authored-By: Claude Opus 4.7 --- .../feature_flags/services/build_configuration.dart | 5 ++++- mobile/test/services/feature_flag_service_test.dart | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/feature_flags/services/build_configuration.dart b/mobile/lib/features/feature_flags/services/build_configuration.dart index deeabc8fad..3a0ccc9ede 100644 --- a/mobile/lib/features/feature_flags/services/build_configuration.dart +++ b/mobile/lib/features/feature_flags/services/build_configuration.dart @@ -39,7 +39,10 @@ class BuildConfiguration { case FeatureFlag.accountSwitching: return const bool.fromEnvironment('FF_ACCOUNT_SWITCHING'); case FeatureFlag.hlsAuthWebPlayer: - return const bool.fromEnvironment('FF_HLS_AUTH_WEB_PLAYER'); + return const bool.fromEnvironment( + 'FF_HLS_AUTH_WEB_PLAYER', + defaultValue: true, + ); case FeatureFlag.profileListFeatures: return const bool.fromEnvironment('FF_PROFILE_LIST_FEATURES'); case FeatureFlag.contentPolicyV2: diff --git a/mobile/test/services/feature_flag_service_test.dart b/mobile/test/services/feature_flag_service_test.dart index 6318695d12..2c8284f88f 100644 --- a/mobile/test/services/feature_flag_service_test.dart +++ b/mobile/test/services/feature_flag_service_test.dart @@ -62,6 +62,14 @@ void main() { expect(service.isEnabled(FeatureFlag.newCameraUI), isTrue); }); + + test('enables web HLS auth playback by default', () async { + when(() => mockPrefs.getBool(any())).thenReturn(null); + + await service.initialize(); + + expect(service.isEnabled(FeatureFlag.hlsAuthWebPlayer), isTrue); + }); }); group('flag management', () {