@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user