fix(subtitles): show search without subtitle tracks

close #1219
This commit is contained in:
edde746
2026-06-01 19:08:17 +02:00
parent a425798bae
commit cb2e56442d
4 changed files with 238 additions and 179 deletions
@@ -6,6 +6,7 @@ import '../../../media/media_source_info.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../mpv/mpv.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
/// Immutable configuration for track/chapter control widgets.
class TrackControlsState {
@@ -124,4 +125,20 @@ class TrackControlsState {
this.onSubtitleDownloaded,
this.subtitleSearchSupported = true,
});
/// Source subtitles can only be selected when playback can be re-opened with
/// a Plex source subtitle stream id.
bool get canUseSourceSubtitles =>
isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
/// External subtitle search needs both a searchable media item and a server
/// that can proxy the OpenSubtitles request.
bool get canSearchSubtitles => ratingKey.isNotEmpty && serverId.isNotEmpty && subtitleSearchSupported;
/// Whether the track sheet should expose subtitle controls at all. This is
/// the single source of truth shared by the toolbar icon and the sheet layout.
bool hasSubtitleControls(Tracks? tracks) {
final playerSubtitles = tracks?.subtitle ?? const <SubtitleTrack>[];
return canUseSourceSubtitles || TrackFilterHelper.hasTracks<SubtitleTrack>(playerSubtitles) || canSearchSubtitles;
}
}
@@ -12,55 +12,16 @@ import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
import 'sheet_column_header.dart';
import 'subtitle_search_sheet.dart';
import '../models/track_controls_state.dart';
import '../helpers/track_filter_helper.dart';
import '../helpers/track_selection_helper.dart';
/// Combined bottom sheet for selecting audio and subtitle tracks side-by-side.
class TrackSheet extends StatelessWidget {
final Player player;
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Function(AudioTrack)? onAudioTrackChanged;
final Function(SubtitleTrack)? onSubtitleTrackChanged;
final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged;
final TrackControlsState trackControlsState;
/// When true, or when a Jellyfin source has external audio, the audio column
/// renders [sourceAudioTracks] and taps are routed to [onSwitchAudioStreamId]
/// instead of using the player's in-stream audio selection.
final bool isTranscoding;
final List<MediaAudioTrack> sourceAudioTracks;
final int? selectedAudioStreamId;
final ValueChanged<int>? onSwitchAudioStreamId;
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final int? selectedSubtitleStreamId;
final ValueChanged<int>? onSwitchSubtitleStreamId;
/// Whether OpenSubtitles search is supported by the active server. Plex
/// proxies the OpenSubtitles plugin; Jellyfin doesn't expose an
/// equivalent today.
final bool subtitleSearchSupported;
const TrackSheet({
super.key,
required this.player,
this.ratingKey = '',
this.serverId = '',
this.mediaTitle,
this.onSubtitleDownloaded,
this.onAudioTrackChanged,
this.onSubtitleTrackChanged,
this.onSecondarySubtitleTrackChanged,
this.isTranscoding = false,
this.sourceAudioTracks = const [],
this.selectedAudioStreamId,
this.onSwitchAudioStreamId,
this.sourceSubtitleTracks = const [],
this.selectedSubtitleStreamId,
this.onSwitchSubtitleStreamId,
this.subtitleSearchSupported = true,
});
const TrackSheet({super.key, required this.player, required this.trackControlsState});
@override
Widget build(BuildContext context) {
@@ -75,12 +36,15 @@ class TrackSheet extends StatelessWidget {
(t) => t?.subtitle ?? [],
);
final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal);
final state = trackControlsState;
final hasExternalSourceAudio = state.sourceAudioTracks.any((track) => track.isExternal);
final useSourceAudio =
(isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null;
final useSourceSubtitles = isTranscoding && sourceSubtitleTracks.isNotEmpty && onSwitchSubtitleStreamId != null;
(state.isTranscoding || hasExternalSourceAudio) &&
state.sourceAudioTracks.length > 1 &&
state.onSwitchAudioStreamId != null;
final useSourceSubtitles = state.canUseSourceSubtitles;
final showAudio = useSourceAudio || playerAudioTracks.length > 1;
final showSubtitles = useSourceSubtitles || subtitleTracks.isNotEmpty;
final showSubtitles = state.hasSubtitleControls(tracks);
final String title;
final IconData icon;
@@ -109,9 +73,9 @@ class TrackSheet extends StatelessWidget {
Widget audioColumnFor(TrackSelection sel, bool showHeader) {
if (useSourceAudio) {
return _SourceAudioColumn(
tracks: sourceAudioTracks,
selectedStreamId: selectedAudioStreamId,
onSelected: onSwitchAudioStreamId!,
tracks: state.sourceAudioTracks,
selectedStreamId: state.selectedAudioStreamId,
onSelected: state.onSwitchAudioStreamId!,
showHeader: showHeader,
);
}
@@ -119,7 +83,7 @@ class TrackSheet extends StatelessWidget {
tracks: playerAudioTracks,
selection: sel,
player: player,
onTrackChanged: onAudioTrackChanged,
onTrackChanged: state.onAudioTrackChanged,
showHeader: showHeader,
);
}
@@ -127,30 +91,18 @@ class TrackSheet extends StatelessWidget {
Widget subtitleColumnFor(TrackSelection sel, bool showHeader) {
if (useSourceSubtitles) {
return _SourceSubtitleColumn(
tracks: sourceSubtitleTracks,
selectedStreamId: selectedSubtitleStreamId,
onSelected: onSwitchSubtitleStreamId!,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
tracks: state.sourceSubtitleTracks,
trackControlsState: state,
showHeader: showHeader,
subtitleSearchSupported: subtitleSearchSupported,
);
}
return _SubtitleColumn(
tracks: subtitleTracks,
selection: sel,
player: player,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
onTrackChanged: onSubtitleTrackChanged,
onSecondaryTrackChanged: onSecondarySubtitleTrackChanged,
supportsSecondary: supportsSecondary,
showHeader: showHeader,
subtitleSearchSupported: subtitleSearchSupported,
trackControlsState: state,
);
}
@@ -249,26 +201,10 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> {
class _SourceSubtitleColumn extends StatefulWidget {
final List<MediaSubtitleTrack> tracks;
final int? selectedStreamId;
final ValueChanged<int> onSelected;
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final TrackControlsState trackControlsState;
final bool showHeader;
final bool subtitleSearchSupported;
const _SourceSubtitleColumn({
required this.tracks,
required this.selectedStreamId,
required this.onSelected,
this.ratingKey = '',
this.serverId = '',
this.mediaTitle,
this.onSubtitleDownloaded,
required this.showHeader,
this.subtitleSearchSupported = true,
});
const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader});
@override
State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState();
@@ -304,7 +240,7 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
isSelected: selectedId == 0,
onTap: () {
OverlaySheetController.of(context).close();
widget.onSelected(0);
widget.trackControlsState.onSwitchSubtitleStreamId!(0);
},
);
}
@@ -316,35 +252,19 @@ class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> {
isSelected: track.id == selectedId,
onTap: () {
OverlaySheetController.of(context).close();
widget.onSelected(track.id);
widget.trackControlsState.onSwitchSubtitleStreamId!(track.id);
},
);
},
),
),
if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[
Divider(height: 1, color: Theme.of(context).dividerColor),
FocusableListTile(
leading: const AppIcon(Symbols.search_rounded),
title: Text(t.videoControls.searchSubtitles),
onTap: () {
OverlaySheetController.of(context).push(
builder: (_) => SubtitleSearchSheet(
ratingKey: widget.ratingKey,
serverId: widget.serverId,
mediaTitle: widget.mediaTitle,
onSubtitleDownloaded: widget.onSubtitleDownloaded,
),
);
},
),
],
..._buildSubtitleSearchFooter(context, widget.trackControlsState),
],
);
}
int _effectiveSelectedStreamId() {
final explicit = widget.selectedStreamId;
final explicit = widget.trackControlsState.selectedSubtitleStreamId;
if (explicit != null && (explicit == 0 || widget.tracks.any((track) => track.id == explicit))) return explicit;
for (final track in widget.tracks) {
if (track.selected) return track.id;
@@ -426,29 +346,17 @@ class _SubtitleColumn extends StatefulWidget {
final List<SubtitleTrack> tracks;
final TrackSelection selection;
final Player player;
final String ratingKey;
final String serverId;
final String? mediaTitle;
final Future<void> Function()? onSubtitleDownloaded;
final Function(SubtitleTrack)? onTrackChanged;
final Function(SubtitleTrack)? onSecondaryTrackChanged;
final bool supportsSecondary;
final bool showHeader;
final bool subtitleSearchSupported;
final TrackControlsState trackControlsState;
const _SubtitleColumn({
required this.tracks,
required this.selection,
required this.player,
this.ratingKey = '',
this.serverId = '',
this.mediaTitle,
this.onSubtitleDownloaded,
this.onTrackChanged,
this.onSecondaryTrackChanged,
this.supportsSecondary = false,
required this.showHeader,
this.subtitleSearchSupported = true,
required this.trackControlsState,
});
@override
@@ -494,22 +402,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
// Turning off primary also clears secondary
if (hasSecondary) {
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
}
widget.player.selectSubtitleTrack(SubtitleTrack.off);
widget.onTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off);
OverlaySheetController.of(context).close();
},
onLongPress: widget.supportsSecondary && hasSecondary
? () {
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
}
: null,
onSecondaryTap: widget.supportsSecondary && hasSecondary
? () {
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
}
: null,
);
@@ -543,10 +451,10 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
// If tapping a track that is currently the secondary, clear secondary first
if (isSecondary) {
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
}
widget.player.selectSubtitleTrack(track);
widget.onTrackChanged?.call(track);
widget.trackControlsState.onSubtitleTrackChanged?.call(track);
OverlaySheetController.of(context).close();
},
onLongPress: widget.supportsSecondary
@@ -554,11 +462,11 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
if (isSecondary) {
// Already secondary — clear it
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
} else if (!isPrimary) {
// Set as secondary (don't close sheet so user sees badge update)
widget.player.selectSecondarySubtitleTrack(track);
widget.onSecondaryTrackChanged?.call(track);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
}
}
: null,
@@ -566,10 +474,10 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
? () {
if (isSecondary) {
widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off);
widget.onSecondaryTrackChanged?.call(SubtitleTrack.off);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off);
} else if (!isPrimary) {
widget.player.selectSecondarySubtitleTrack(track);
widget.onSecondaryTrackChanged?.call(track);
widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track);
}
}
: null,
@@ -577,24 +485,30 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
},
),
),
if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[
Divider(height: 1, color: Theme.of(context).dividerColor),
FocusableListTile(
leading: const AppIcon(Symbols.search_rounded),
title: Text(t.videoControls.searchSubtitles),
onTap: () {
OverlaySheetController.of(context).push(
builder: (_) => SubtitleSearchSheet(
ratingKey: widget.ratingKey,
serverId: widget.serverId,
mediaTitle: widget.mediaTitle,
onSubtitleDownloaded: widget.onSubtitleDownloaded,
),
);
},
),
],
..._buildSubtitleSearchFooter(context, widget.trackControlsState),
],
);
}
}
List<Widget> _buildSubtitleSearchFooter(BuildContext context, TrackControlsState state) {
if (!state.canSearchSubtitles) return const [];
return [
Divider(height: 1, color: Theme.of(context).dividerColor),
FocusableListTile(
leading: const AppIcon(Symbols.search_rounded),
title: Text(t.videoControls.searchSubtitles),
onTap: () {
OverlaySheetController.of(context).push(
builder: (_) => SubtitleSearchSheet(
ratingKey: state.ratingKey,
serverId: state.serverId,
mediaTitle: state.mediaTitle,
onSubtitleDownloaded: state.onSubtitleDownloaded,
),
);
},
),
];
}
@@ -18,7 +18,6 @@ import '../sheets/queue_sheet.dart';
import '../sheets/track_sheet.dart';
import '../sheets/video_settings_sheet.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
import '../video_control_button.dart';
/// Row of track and chapter control buttons for the video player
@@ -86,9 +85,6 @@ class TrackChapterControls extends StatelessWidget {
VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen;
VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop;
Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion;
Function(AudioTrack)? get onAudioTrackChanged => trackControlsState.onAudioTrackChanged;
Function(SubtitleTrack)? get onSubtitleTrackChanged => trackControlsState.onSubtitleTrackChanged;
Function(SubtitleTrack)? get onSecondarySubtitleTrackChanged => trackControlsState.onSecondarySubtitleTrackChanged;
VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes;
VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide;
VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide;
@@ -103,9 +99,6 @@ class TrackChapterControls extends StatelessWidget {
bool get subtitlesVisible => trackControlsState.subtitlesVisible;
bool get showQueueButton => trackControlsState.showQueueButton;
Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected;
String get ratingKey => trackControlsState.ratingKey;
String? get mediaTitle => trackControlsState.mediaTitle;
Future<void> Function()? get onSubtitleDownloaded => trackControlsState.onSubtitleDownloaded;
/// Handle key event for button navigation
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) {
@@ -257,13 +250,11 @@ class TrackChapterControls extends StatelessWidget {
// Combined audio & subtitles button
{
final currentIndex = buttonIndex;
final hasSourceSubs =
trackControlsState.sourceSubtitleTracks.isNotEmpty && trackControlsState.onSwitchSubtitleStreamId != null;
final hasSubs = _hasSubtitles(tracks) || hasSourceSubs;
final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks);
final selectedSub = player.state.track.subtitle;
final hasActiveSubtitle = selectedSub != null && selectedSub.id != 'no';
final isHidden = hasSubs && hasActiveSubtitle && !subtitlesVisible;
final icon = hasSubs
final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible;
final icon = hasSubtitleControls
? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded)
: Symbols.audiotrack_rounded;
buttons.add(
@@ -278,24 +269,7 @@ class TrackChapterControls extends StatelessWidget {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => TrackSheet(
player: player,
ratingKey: ratingKey,
serverId: serverId,
mediaTitle: mediaTitle,
onSubtitleDownloaded: onSubtitleDownloaded,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
isTranscoding: trackControlsState.isTranscoding,
sourceAudioTracks: trackControlsState.sourceAudioTracks,
selectedAudioStreamId: trackControlsState.selectedAudioStreamId,
onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId,
sourceSubtitleTracks: trackControlsState.sourceSubtitleTracks,
selectedSubtitleStreamId: trackControlsState.selectedSubtitleStreamId,
onSwitchSubtitleStreamId: trackControlsState.onSwitchSubtitleStreamId,
subtitleSearchSupported: trackControlsState.subtitleSearchSupported,
),
builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState),
)
.whenComplete(() => onStartAutoHide?.call());
},
@@ -480,11 +454,6 @@ class TrackChapterControls extends StatelessWidget {
return count;
}
bool _hasSubtitles(Tracks? tracks) {
if (tracks == null) return false;
return TrackFilterHelper.hasTracks<SubtitleTrack>(tracks.subtitle);
}
IconData _getBoxFitIcon(int mode) {
switch (mode) {
case 0:
+159
View File
@@ -0,0 +1,159 @@
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/widgets/video_controls/models/track_controls_state.dart';
import 'package:plezy/widgets/video_controls/sheets/track_sheet.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);
});
});
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],
onSwitchSubtitleStreamId: (_) {},
).hasSubtitleControls(const Tracks()),
isTrue,
);
});
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<void> _pumpTrackSheet(
WidgetTester tester, {
required Player player,
required TrackControlsState trackControlsState,
}) async {
await tester.pumpWidget(
MaterialApp(
home: 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})
: _state = PlayerState(tracks: tracks, track: track),
_streams = PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
final PlayerState _state;
final PlayerStreams _streams;
@override
PlayerState get state => _state;
@override
PlayerStreams get streams => _streams;
@override
bool get supportsSecondarySubtitles => false;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}