Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions mobile/lib/screens/feed/pooled_fullscreen_video_feed_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,9 @@ class _FullscreenFeedContentState extends ConsumerState<FullscreenFeedContent>
}) {
return _WebFullscreenItem(
video: video,
index: index,
isActive: isActive,
feedKey: _webFeedKey,
isOwnVideo:
currentUserPubkey ==
video.pubkey,
Expand Down Expand Up @@ -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,
Expand All @@ -1168,7 +1172,9 @@ class _WebFullscreenItem extends ConsumerWidget {
});

final VideoEvent video;
final int index;
final bool isActive;
final GlobalKey<WebVideoFeedState> feedKey;
final bool isOwnVideo;
final VideoPlayerController? controller;
final String? contextTitle;
Expand Down Expand Up @@ -1287,7 +1293,11 @@ class _WebFullscreenItem extends ConsumerWidget {
),
),
),
_WebFullscreenLoadingModerationOverlay(video: video),
_WebFullscreenLoadingModerationOverlay(
video: video,
index: index,
feedKey: feedKey,
),
],
),
),
Expand All @@ -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<WebVideoFeedState> feedKey;

@override
ConsumerState<_WebFullscreenLoadingModerationOverlay> createState() =>
Expand Down Expand Up @@ -1327,6 +1343,11 @@ class _WebFullscreenLoadingModerationOverlayState
);
if (headers == null || !mounted) return;

context.read<VideoPlaybackStatusCubit>().report(
widget.video.id,
PlaybackStatus.ready,
);
widget.feedKey.currentState?.retryPlayback(widget.index, headers: headers);
setState(() {
_dismissedAfterVerify = true;
});
Expand Down
33 changes: 29 additions & 4 deletions mobile/lib/widgets/web_video_feed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ class WebVideoFeedState extends State<WebVideoFeed> {
final Map<int, VoidCallback> _controllerListeners = {};
final Map<int, Duration> _lastPositions = {};
final Map<int, bool> _armedForCompletion = {};
final Map<String, Map<String, String>> _requestHeadersByVideoId = {};

int get currentIndex => _currentIndex;
int get videoCount => widget.videos.length;
Expand Down Expand Up @@ -203,6 +204,8 @@ class WebVideoFeedState extends State<WebVideoFeed> {
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 = <int, VideoPlayerController>{
for (final entry in current.entries)
Expand Down Expand Up @@ -231,8 +234,13 @@ class WebVideoFeedState extends State<WebVideoFeed> {
/// 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<WebVideoPlayerState> disposedKey,
) {
if (identical(_playerKeys[index], disposedKey)) {
_playerKeys.remove(index);
}
final current = _controllers.value;
final controller = current[index];
if (controller == null) return;
Expand All @@ -244,6 +252,23 @@ class WebVideoFeedState extends State<WebVideoFeed> {
});
}

void retryPlayback(int index, {Map<String, String>? 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);
_controllers.value = Map<int, VideoPlayerController>.of(
_controllers.value,
)..remove(index);
}
_playerKeys.remove(index);
setState(() {});
}

Future<void> animateToPage(int index) async {
if (!mounted || widget.videos.isEmpty) return;

Expand Down Expand Up @@ -375,7 +400,7 @@ class WebVideoFeedState extends State<WebVideoFeed> {
isActive: isActive,
videoUrl: playbackUrl,
playerKey: playerKey,
headers: widget.headers,
headers: _requestHeadersByVideoId[video.id] ?? widget.headers,
controllerFactory: widget.controllerFactory,
controllersListenable: _controllers,
itemBuilder: widget.itemBuilder,
Expand All @@ -392,7 +417,7 @@ class WebVideoFeedState extends State<WebVideoFeed> {
},
onDisposed: () {
if (!mounted) return;
_onPlayerDisposed(index);
_onPlayerDisposed(index, playerKey);
},
onError: () {
if (!mounted) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -313,6 +314,7 @@ void main() {
FullscreenFeedState? state,
List<dynamic>? additionalOverrides,
VideoFeedControllerFactory? controllerFactory,
WebVideoPlayerControllerFactory? webControllerFactory,
String? contextTitle,
}) {
final effectiveState = state ?? const FullscreenFeedState();
Expand Down Expand Up @@ -344,6 +346,7 @@ void main() {
controllerFactory:
controllerFactory ??
((videos, initialIndex) => defaultController),
webControllerFactory: webControllerFactory,
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ void main() {
);
final moderationService = _MockVideoModerationStatusService();
final mediaAuthInterceptor = _FakeMediaAuthInterceptor();
final requestHeaders = <Map<String, String>>[];
when(() => mockBloc.state).thenReturn(state);
when(() => moderationService.fetchStatus(sha256)).thenAnswer(
(_) async => const VideoModerationStatus(
Expand Down Expand Up @@ -276,8 +277,10 @@ void main() {
),
],
child: FullscreenFeedContent(
webControllerFactory: ({required url, required headers}) =>
webController,
webControllerFactory: ({required url, required headers}) {
requestHeaders.add(headers);
return FakeVideoPlayerController();
},
),
),
),
Expand All @@ -293,6 +296,13 @@ void main() {
await tester.pump();

expect(mediaAuthInterceptor.didHandleUnauthorizedMedia, isTrue);
expect(
requestHeaders,
equals([
const <String, String>{},
const {'Authorization': 'Bearer test'},
]),
);
expect(find.text(l10n.videoErrorAgeRestricted), findsNothing);
},
skip: !kIsWeb,
Expand Down
8 changes: 8 additions & 0 deletions mobile/test/services/feature_flag_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
64 changes: 64 additions & 0 deletions mobile/test/widgets/web_video_feed_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,70 @@ void main() {
expect(activeIndex, 1);
});

testWidgets('retryPlayback recreates the player for an index', (
tester,
) async {
final key = GlobalKey<WebVideoFeedState>();
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('retryPlayback applies verified headers to recreated player', (
tester,
) async {
final key = GlobalKey<WebVideoFeedState>();
final requestHeaders = <Map<String, String>>[];

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 <String, String>{}]));

const verifiedHeaders = {'Authorization': 'Bearer verified'};
key.currentState!.retryPlayback(0, headers: verifiedHeaders);
await tester.pump();
await tester.pump();

expect(
requestHeaders,
equals([const <String, String>{}, verifiedHeaders]),
);
});

testWidgets(
'drops controller entries when WebVideoPlayer items are disposed',
(tester) async {
Expand Down
Loading