import 'dart:async'; import 'dart:ui' show Tristate; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { LocaleSettings.setLocaleSync(AppLocale.en); }); group('TrackSheet subtitle controls', () { testWidgets('shows subtitle search when Plex search is available without subtitle tracks', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( audio: [ AudioTrack(id: 'a1'), AudioTrack(id: 'a2'), ], ), track: const TrackSelection( audio: AudioTrack(id: 'a1'), subtitle: SubtitleTrack.off, ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: const TrackControlsState( ratingKey: '123', serverId: 'plex-server', subtitleSearchSupported: true, ), ); expect(find.text('Search Subtitles'), findsOneWidget); }); testWidgets('hides subtitle search when external subtitle search is unsupported', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( audio: [ AudioTrack(id: 'a1'), AudioTrack(id: 'a2'), ], ), track: const TrackSelection( audio: AudioTrack(id: 'a1'), subtitle: SubtitleTrack.off, ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: const TrackControlsState( ratingKey: '123', serverId: 'jellyfin-server', subtitleSearchSupported: false, ), ); expect(find.text('Search Subtitles'), findsNothing); }); testWidgets('keeps source stream zero distinct from turning subtitles off', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks(), track: const TrackSelection(subtitle: SubtitleTrack.off), ); PlaybackSourceSubtitleChoice? switchedChoice; await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( isTranscoding: true, sourceSubtitleTracks: [MediaSubtitleTrack(id: 0, title: 'Stream zero', selected: false, forced: false)], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.off(), onSwitchSubtitle: (choice) async { switchedChoice = choice; }, subtitleSearchSupported: false, ), ); await tester.tap(find.text('Stream zero')); expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0)); }); testWidgets('keeps an unloaded source sidecar selectable', (tester) async { const remoteUri = 'https://example.test/source/remote.ass'; final player = _FakeTrackSheetPlayer( tracks: const Tracks( subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), ), ); PlaybackSourceSubtitleChoice? switchedSourceChoice; await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false), MediaSubtitleTrack( id: 2, title: 'Remote sidecar', codec: 'ass', external: true, selected: false, forced: false, ), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), sourceSubtitleSidecars: [ PlaybackSubtitleSidecar( sourceStreamId: 2, track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'), ), ], onSwitchSubtitle: (choice) async { switchedSourceChoice = choice; }, subtitleSearchSupported: false, ), ); expect(find.text('English'), findsOneWidget); expect(find.text('Remote sidecar'), findsOneWidget); await tester.tap(find.text('Remote sidecar')); expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2)); }); testWidgets('does not duplicate a previously attached sidecar after selecting an embedded track', (tester) async { const remoteUri = 'https://example.test/source/attached.ass'; final player = _FakeTrackSheetPlayer( tracks: const Tracks( subtitle: [ SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true, uri: remoteUri), ], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false), MediaSubtitleTrack( id: 2, title: 'Remote sidecar', codec: 'ass', external: true, selected: false, forced: false, ), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), sourceSubtitleSidecars: [ PlaybackSubtitleSidecar( sourceStreamId: 2, track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'), ), ], onSwitchSubtitle: (_) async {}, subtitleSearchSupported: false, ), ); expect(find.text('Remote sidecar'), findsOneWidget); }); testWidgets('keeps an unloaded source sidecar beside an unrelated downloaded subtitle', (tester) async { const sourceUri = 'https://example.test/source/unloaded.ass'; const downloadedUri = 'file:///tmp/downloaded.srt'; final player = _FakeTrackSheetPlayer( tracks: const Tracks( subtitle: [ SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'), SubtitleTrack( id: 'downloaded', title: 'Downloaded subtitle', codec: 'srt', isExternal: true, uri: downloadedUri, ), ], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'), ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack( id: 2, title: 'Remote sidecar', codec: 'ass', external: true, selected: false, forced: false, ), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), sourceSubtitleSidecars: [ PlaybackSubtitleSidecar( sourceStreamId: 2, track: SubtitleTrack.uri(sourceUri, title: 'Remote sidecar', codec: 'ass'), ), ], onSwitchSubtitle: (_) async {}, subtitleSearchSupported: false, ), ); expect(find.text('Downloaded subtitle'), findsOneWidget); expect(find.text('Remote sidecar'), findsOneWidget); }); testWidgets('keeps a source sheet open until the async selection commits', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), ), ); final selectionGate = Completer(); late BuildContext hostContext; await tester.pumpWidget( MaterialApp( theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Builder( builder: (context) { hostContext = context; return const Scaffold(body: SizedBox.expand()); }, ), ), ), ); final sheetResult = OverlaySheetController.of(hostContext).show( builder: (_) => SizedBox( height: 400, child: TrackSheet( player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false), MediaSubtitleTrack( id: 2, title: 'Remote sidecar', codec: 'ass', external: true, selected: false, forced: false, ), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), sourceSubtitleSidecars: [ PlaybackSubtitleSidecar( sourceStreamId: 2, track: SubtitleTrack.uri( 'https://example.test/source/pending.ass', title: 'Remote sidecar', codec: 'ass', ), ), ], onSwitchSubtitle: (_) => selectionGate.future, subtitleSearchSupported: false, ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Remote sidecar')); await tester.pump(); expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.text('Remote sidecar'), findsOneWidget); selectionGate.complete(); await tester.pumpAndSettle(); await sheetResult; expect(find.text('Remote sidecar'), findsNothing); }); testWidgets('direct-play embedded selection stays on the native player path', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( subtitle: [ SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), SubtitleTrack(id: 's2', language: 'swe', codec: 'srt'), ], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'), ), ); PlaybackSourceSubtitleChoice? switchedSourceChoice; await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack(id: 1, languageCode: 'eng', selected: true, forced: false), MediaSubtitleTrack(id: 2, languageCode: 'swe', selected: false, forced: false), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), onSwitchSubtitle: (choice) async { switchedSourceChoice = choice; }, subtitleSearchSupported: false, ), ); await tester.tap(find.text('Swedish')); expect(player.lastSelectedSubtitle?.id, 's2'); expect(switchedSourceChoice, isNull); }); testWidgets('does not append source sidecars already loaded as primary and secondary tracks', (tester) async { const primaryUri = 'https://example.test/source/primary.ass'; const secondaryUri = 'https://example.test/source/secondary.ass'; final player = _FakeTrackSheetPlayer( supportsSecondarySubtitles: true, tracks: const Tracks( subtitle: [ SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri), SubtitleTrack(id: 's2', title: 'Secondary sidecar', codec: 'ass', isExternal: true, uri: secondaryUri), ], ), track: const TrackSelection( subtitle: SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri), secondarySubtitle: SubtitleTrack( id: 's2', title: 'Secondary sidecar', codec: 'ass', isExternal: true, uri: secondaryUri, ), ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: TrackControlsState( sourceSubtitleTracks: [ MediaSubtitleTrack( id: 1, title: 'Primary sidecar', codec: 'ass', external: true, selected: true, forced: false, ), MediaSubtitleTrack( id: 2, title: 'Secondary sidecar', codec: 'ass', external: true, selected: false, forced: false, ), ], selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1), selectedSecondarySubtitleStreamId: 2, sourceSubtitleSidecars: [ PlaybackSubtitleSidecar( sourceStreamId: 1, track: SubtitleTrack.uri(primaryUri, title: 'Primary sidecar', codec: 'ass'), ), PlaybackSubtitleSidecar( sourceStreamId: 2, track: SubtitleTrack.uri(secondaryUri, title: 'Secondary sidecar', codec: 'ass'), ), ], onSwitchSubtitle: (_) async {}, subtitleSearchSupported: false, ), ); expect(find.text('Primary sidecar'), findsOneWidget); expect(find.text('Secondary sidecar'), findsOneWidget); }); }); group('TrackSheet two-line labels', () { testWidgets('renders language as the primary line and tech detail below', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( audio: [ AudioTrack(id: 'a1', language: 'eng', codec: 'aac', channels: 2), AudioTrack( id: 'a2', title: 'Dolby Digital Plus 5.1 with Atmos', language: 'ta', codec: 'eac3', channels: 6, ), ], ), track: const TrackSelection( audio: AudioTrack(id: 'a1', language: 'eng', codec: 'aac', channels: 2), subtitle: SubtitleTrack.off, ), ); await _pumpTrackSheet( tester, player: player, trackControlsState: const TrackControlsState(subtitleSearchSupported: false), ); expect(find.text('English'), findsOneWidget); expect(find.text('AAC · Stereo'), findsOneWidget); expect(find.text('Tamil'), findsOneWidget); expect(find.text('Dolby Digital Plus 5.1 with Atmos · E-AC3 · 5.1'), findsOneWidget); final englishTile = find.ancestor(of: find.text('English'), matching: find.byType(ListTile)); final tamilTile = find.ancestor(of: find.text('Tamil'), matching: find.byType(ListTile)); expect(tester.getSemantics(englishTile).getSemanticsData().flagsCollection.isSelected, Tristate.isTrue); expect(tester.getSemantics(tamilTile).getSemanticsData().flagsCollection.isSelected, Tristate.isFalse); }); }); group('TrackControlsState.hasSubtitleControls', () { test('counts source subtitles only when source switching is available', () { final sourceSubtitle = MediaSubtitleTrack(id: 1, selected: false, forced: false); expect( TrackControlsState( isTranscoding: true, sourceSubtitleTracks: [sourceSubtitle], ).hasSubtitleControls(const Tracks()), isFalse, ); expect( TrackControlsState( isTranscoding: true, sourceSubtitleTracks: [sourceSubtitle], onSwitchSubtitle: (_) async {}, ).hasSubtitleControls(const Tracks()), isTrue, ); }); test('live server-side tracks enable the source column without a transcode flag', () { // A Plex live session delivers subtitles by rebuilding the stream with // the chosen track burned in (DVB bitmap subtitles, issue #1983). final liveTrack = MediaSubtitleTrack(id: 92, codec: 'dvb_subtitle', selected: false, forced: false); final state = TrackControlsState(isLive: true, sourceSubtitleTracks: [liveTrack], onSwitchSubtitle: (_) async {}); expect(state.canUseSourceSubtitles, isTrue); expect(state.hasSubtitleControls(const Tracks()), isTrue); // A live stream exposing none keeps the native list so in-band CEA // captions stay client-rendered (issue #1590). const withoutTracks = TrackControlsState(isLive: true, subtitleSearchSupported: false); expect(withoutTracks.canUseSourceSubtitles, isFalse); expect(withoutTracks.hasSubtitleControls(const Tracks()), isFalse); }); test('counts direct-play source sidecars without replacing native tracks', () { const sourceUri = 'https://example.test/source/available.ass'; final sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false); final state = TrackControlsState( sourceSubtitleTracks: [sourceSidecar], sourceSubtitleSidecars: [ PlaybackSubtitleSidecar(sourceStreamId: sourceSidecar.id, track: SubtitleTrack.uri(sourceUri)), ], onSwitchSubtitle: (_) async {}, ); expect(state.canUseSourceSubtitles, isFalse); expect(state.directPlaySourceSidecars, [sourceSidecar]); expect(state.hasSubtitleControls(const Tracks()), isTrue); }); test('does not misclassify a Jellyfin external-delivery embedded row as a sidecar', () { final sourceTrack = MediaSubtitleTrack(id: 1, usesExternalDelivery: true, selected: false, forced: false); final state = TrackControlsState(sourceSubtitleTracks: [sourceTrack], onSwitchSubtitle: (_) async {}); expect(state.directPlaySourceSidecars, isEmpty); }); test('ignores player subtitle placeholders', () { const state = TrackControlsState(subtitleSearchSupported: false); expect(state.hasSubtitleControls(const Tracks(subtitle: [SubtitleTrack.auto, SubtitleTrack.off])), isFalse); expect(state.hasSubtitleControls(const Tracks(subtitle: [SubtitleTrack(id: 's1')])), isTrue); }); }); } Future _pumpTrackSheet( WidgetTester tester, { required Player player, required TrackControlsState trackControlsState, }) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: SizedBox( width: 700, height: 400, child: TrackSheet(player: player, trackControlsState: trackControlsState), ), ), ), ), ); await tester.pump(); } class _FakeTrackSheetPlayer implements Player { _FakeTrackSheetPlayer({ required Tracks tracks, required TrackSelection track, this.supportsSecondarySubtitles = false, }) : _state = PlayerState(tracks: tracks, track: track), _streams = PlayerStreams( playing: const Stream.empty(), completed: const Stream.empty(), buffering: const Stream.empty(), position: const Stream.empty(), duration: const Stream.empty(), seekable: const Stream.empty(), buffer: const Stream.empty(), volume: const Stream.empty(), rate: const Stream.empty(), tracks: const Stream.empty(), track: const Stream.empty(), log: const Stream.empty(), error: const Stream.empty(), audioDevice: const Stream.empty(), audioDevices: const Stream>.empty(), bufferRanges: const Stream>.empty(), playbackRestart: const Stream.empty(), backendSwitched: const Stream.empty(), ); final PlayerState _state; final PlayerStreams _streams; SubtitleTrack? lastSelectedSubtitle; @override final bool supportsSecondarySubtitles; @override PlayerState get state => _state; @override PlayerStreams get streams => _streams; @override Future selectSubtitleTrack(SubtitleTrack track) async { lastSelectedSubtitle = track; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }