Skip to content

Commit 1a05cb3

Browse files
committed
fix: resolve subtitle visibility leakage, UI track sync, and play queue missing episodes
- Prevent subtitle visibility state leaking between tracks on MediaKit backend. - Filter out missing/virtual episodes and specials from play queues. - Asynchronously query native subtitle track to synchronize the UI subtitle track selector when auto-selected. - Fix broken unit tests from audio pref migrations.
1 parent 0a0371d commit 1a05cb3

10 files changed

Lines changed: 252 additions & 90 deletions

lib/di/modules/playback_module.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import '../../preference/preference_constants.dart';
2323
import '../../preference/user_preferences.dart';
2424
import '../../syncplay/syncplay_manager.dart';
2525
import '../../util/platform_detection.dart';
26+
import '../../util/episode_playability.dart';
2627

2728
final _getIt = GetIt.instance;
2829

@@ -316,11 +317,14 @@ Future<List<dynamic>> _nextSeasonItemsProvider(
316317
seriesId: seriesId,
317318
seasonId: nextSeasonId,
318319
);
319-
if (nextSeasonEpisodes.isEmpty ||
320-
nextSeasonEpisodes.first.mediaSources.isEmpty) {
320+
final playableEpisodes = nextSeasonEpisodes
321+
.where(isEligibleNextEpisodeCandidate)
322+
.toList();
323+
if (playableEpisodes.isEmpty ||
324+
playableEpisodes.first.mediaSources.isEmpty) {
321325
return const <dynamic>[];
322326
}
323-
return nextSeasonEpisodes;
327+
return playableEpisodes;
324328
} catch (_) {
325329
return const <dynamic>[];
326330
}

lib/playback/html_video_backend_stub.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ class HtmlVideoBackend implements PlayerBackend {
8585
@override
8686
Future<void> setAudioTrack(int index) async {}
8787

88+
@override
89+
int? get activeSubtitleTrackIndex => null;
90+
91+
@override
92+
Future<int?> getActiveSubtitleTrackIndexAsync() async => null;
93+
8894
@override
8995
Future<void> setSubtitleTrack(
9096
int index, {

lib/playback/html_video_backend_web.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ class HtmlVideoBackend implements PlayerBackend {
440440
Future<void> setAudioTrack(int index) async {
441441
}
442442

443+
@override
444+
int? get activeSubtitleTrackIndex => null;
445+
446+
@override
447+
Future<int?> getActiveSubtitleTrackIndexAsync() async => null;
448+
443449
@override
444450
Future<void> setSubtitleTrack(
445451
int index, {

lib/playback/media3_player_backend.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,12 @@ class Media3PlayerBackend implements PlayerBackend {
543543
await _invoke<void>('setAudioTrack', {'index': index});
544544
}
545545

546+
@override
547+
int? get activeSubtitleTrackIndex => null;
548+
549+
@override
550+
Future<int?> getActiveSubtitleTrackIndexAsync() async => null;
551+
546552
@override
547553
Future<void> setSubtitleTrack(
548554
int index, {

lib/playback/media_kit_player_backend.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,17 @@ class MediaKitPlayerBackend implements PlayerBackend {
543543
await _applyAudioPassthroughOptions();
544544
await _applyCustomMpvConfIfEnabled();
545545
await _applyAssOverrideMode();
546+
547+
if (_player.platform is NativePlayer) {
548+
final native = _player.platform as NativePlayer;
549+
await _nativeSetProperty(native, 'sid', 'auto');
550+
await _nativeSetProperty(native, 'secondary-sid', 'no');
551+
await _nativeSetProperty(native, 'sub-visibility', 'yes');
552+
if (_useLibass) {
553+
await _nativeSetProperty(native, 'sub-ass', 'yes');
554+
}
555+
}
556+
546557
final media = Media(url);
547558
final openPaused = startPosition > Duration.zero;
548559
await _player.open(media, play: !openPaused);
@@ -1226,6 +1237,56 @@ class MediaKitPlayerBackend implements PlayerBackend {
12261237
}
12271238
}
12281239

1240+
@override
1241+
int? get activeSubtitleTrackIndex {
1242+
if (_player.platform is! NativePlayer) {
1243+
return null;
1244+
}
1245+
try {
1246+
final active = _player.state.track.subtitle;
1247+
if (active.id == 'no') {
1248+
return -1;
1249+
}
1250+
if (active.id == 'auto') {
1251+
return null;
1252+
}
1253+
final subtitleTracks = _player.state.tracks.subtitle;
1254+
final playableSubtitleTracks = subtitleTracks
1255+
.where((t) => t.id != 'auto' && t.id != 'no')
1256+
.toList();
1257+
final idx = playableSubtitleTracks.indexWhere((t) => t.id == active.id);
1258+
if (idx >= 0) {
1259+
return idx + 1;
1260+
}
1261+
} catch (_) {}
1262+
return null;
1263+
}
1264+
1265+
@override
1266+
Future<int?> getActiveSubtitleTrackIndexAsync() async {
1267+
if (_player.platform is! NativePlayer) {
1268+
return null;
1269+
}
1270+
try {
1271+
final native = _player.platform as NativePlayer;
1272+
final sid = await _tryNativeGetProperty(native, 'sid');
1273+
if (sid == 'no' || sid == null) {
1274+
return -1;
1275+
}
1276+
final trackList = await _tryNativeGetProperty(native, 'track-list');
1277+
final subtitleIds = _extractSubtitleTrackIds(trackList);
1278+
final sidInt = int.tryParse(sid);
1279+
if (sidInt != null) {
1280+
final idx = subtitleIds.indexOf(sidInt);
1281+
if (idx >= 0) {
1282+
return idx + 1;
1283+
}
1284+
return sidInt;
1285+
}
1286+
} catch (_) {}
1287+
return null;
1288+
}
1289+
12291290
@override
12301291
Future<void> setSubtitleTrack(
12311292
int mpvTrackId, {

lib/playback/tizen_player_backend.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,12 @@ class TizenPlayerBackend implements PlayerBackend {
279279
await _controller?.setPlaybackSpeed(speed);
280280
}
281281

282+
@override
283+
int? get activeSubtitleTrackIndex => null;
284+
285+
@override
286+
Future<int?> getActiveSubtitleTrackIndexAsync() async => null;
287+
282288
// The standard video_player API exposes no runtime track selection; tracks are
283289
// selected server-side via the device profile / transcoding instead.
284290
@override

lib/ui/screens/detail/item_detail_screen.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5883,7 +5883,7 @@ class _ActionButtonsState extends State<_ActionButtons> {
58835883
);
58845884

58855885
case 'Season':
5886-
final episodes = viewModel.episodes;
5886+
final episodes = viewModel.episodes.where(isEligibleNextEpisodeCandidate).toList();
58875887
if (episodes.isEmpty) return;
58885888
final startIndex = resume
58895889
? episodes.indexWhere(
@@ -5939,11 +5939,12 @@ class _ActionButtonsState extends State<_ActionButtons> {
59395939
if (!context.mounted) return;
59405940

59415941
if (episodes.length > 1) {
5942-
final startIndex = episodes.indexWhere((e) => e.id == item.id);
5942+
final playableEpisodes = episodes.where((e) => e.id == item.id || isEligibleNextEpisodeCandidate(e)).toList();
5943+
final startIndex = playableEpisodes.indexWhere((e) => e.id == item.id);
59435944
final idx = startIndex >= 0 ? startIndex : 0;
5944-
final selectedEpisode = episodes[idx];
5945+
final selectedEpisode = playableEpisodes[idx];
59455946
final episodeQueue = _truncateQueueIfImmediateNextUnplayable(
5946-
episodes,
5947+
playableEpisodes,
59475948
startIndex: idx,
59485949
);
59495950

@@ -6102,10 +6103,11 @@ class _ActionButtonsState extends State<_ActionButtons> {
61026103
) async {
61036104
final manager = GetIt.instance<PlaybackManager>();
61046105
final queue = await _shuffleQueueForItem(item);
6105-
if (queue.length < 2) return;
6106+
final playableQueue = queue.where((e) => isEligibleNextEpisodeCandidate(e) || e.id == item.id).toList();
6107+
if (playableQueue.length < 2) return;
61066108
if (!context.mounted) return;
61076109

6108-
final shuffled = List<AggregatedItem>.from(queue)..shuffle();
6110+
final shuffled = List<AggregatedItem>.from(playableQueue)..shuffle();
61096111
final isAudio = shuffled.every((queuedItem) {
61106112
final mediaType = queuedItem.rawData['MediaType'] as String?;
61116113
return queuedItem.type == 'Audio' || mediaType == 'Audio';

lib/ui/screens/playback/video_player_screen.dart

Lines changed: 80 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -5837,88 +5837,90 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen>
58375837
final canDownloadRemote =
58385838
!audio && item is AggregatedItem && _canDownloadRemoteSubtitles(item);
58395839

5840-
final int? currentStreamIndex;
5841-
if (audio) {
5842-
currentStreamIndex =
5843-
_manager.audioStreamIndex ??
5844-
streams.where((s) => s['IsDefault'] == true).firstOrNull?['Index']
5845-
as int?;
5846-
} else {
5847-
final subIdx = _manager.subtitleStreamIndex;
5848-
currentStreamIndex =
5849-
subIdx ??
5850-
streams.where((s) => s['IsDefault'] == true).firstOrNull?['Index']
5851-
as int?;
5852-
}
5853-
final isSubsOff = !audio && _manager.subtitleStreamIndex == -1;
5854-
5855-
final options = <TrackOption>[
5856-
if (!audio) TrackOption(label: l10n.off),
5857-
...optionStreams.asMap().entries.map((entry) {
5858-
final index = entry.key;
5859-
final trackNumber = index + 1;
5860-
final s = entry.value;
5861-
final displayTitle = s['DisplayTitle'] as String?;
5862-
final title = s['Title'] as String?;
5863-
final language = s['Language'] as String?;
5864-
final codec = s['Codec'] as String?;
5865-
final label =
5866-
displayTitle ??
5867-
title ??
5868-
language ??
5869-
l10n.streamTypeFallback(streamType, index + 1);
5870-
final subtitle = audio
5871-
? [
5872-
if (language != null && displayTitle != null) language,
5873-
if (codec != null) codec.toUpperCase(),
5874-
if (s['Channels'] != null) '${s['Channels']}ch',
5875-
].join(' · ')
5876-
: (() {
5877-
final subtitleType =
5878-
((codec == null || codec.isEmpty) ? 'Unknown' : codec)
5879-
.toUpperCase();
5880-
final deliveryMethod = (s['DeliveryMethod'] as String?)
5881-
?.trim()
5882-
.toLowerCase();
5883-
final location = s['IsExternal'] == true
5884-
? 'External'
5885-
: (deliveryMethod == 'embed' ? 'Embedded' : 'Internal');
5886-
return '$subtitleType · $location';
5887-
})();
5888-
return TrackOption(
5889-
label: '$trackNumber - $label',
5890-
subtitle: subtitle.isNotEmpty ? subtitle : null,
5891-
scrollLabel: true,
5892-
scrollSubtitle: true,
5893-
);
5894-
}),
5895-
if (canDownloadRemote)
5896-
TrackOption(
5897-
label: l10n.downloadSubtitlesLabel,
5898-
subtitle: l10n.searchOpenSubtitlesPlugin,
5899-
),
5900-
];
5840+
unawaited(() async {
5841+
final int? currentStreamIndex;
5842+
if (audio) {
5843+
currentStreamIndex =
5844+
_manager.audioStreamIndex ??
5845+
streams.where((s) => s['IsDefault'] == true).firstOrNull?['Index']
5846+
as int?;
5847+
} else {
5848+
final subIdx = await _manager.getSubtitleStreamIndexAsync();
5849+
currentStreamIndex =
5850+
subIdx ??
5851+
streams.where((s) => s['IsDefault'] == true).firstOrNull?['Index']
5852+
as int?;
5853+
}
5854+
final isSubsOff = !audio && currentStreamIndex == -1;
5855+
5856+
final options = <TrackOption>[
5857+
if (!audio) TrackOption(label: l10n.off),
5858+
...optionStreams.asMap().entries.map((entry) {
5859+
final index = entry.key;
5860+
final trackNumber = index + 1;
5861+
final s = entry.value;
5862+
final displayTitle = s['DisplayTitle'] as String?;
5863+
final title = s['Title'] as String?;
5864+
final language = s['Language'] as String?;
5865+
final codec = s['Codec'] as String?;
5866+
final label =
5867+
displayTitle ??
5868+
title ??
5869+
language ??
5870+
l10n.streamTypeFallback(streamType, index + 1);
5871+
final subtitle = audio
5872+
? [
5873+
if (language != null && displayTitle != null) language,
5874+
if (codec != null) codec.toUpperCase(),
5875+
if (s['Channels'] != null) '${s['Channels']}ch',
5876+
].join(' · ')
5877+
: (() {
5878+
final subtitleType =
5879+
((codec == null || codec.isEmpty) ? 'Unknown' : codec)
5880+
.toUpperCase();
5881+
final deliveryMethod = (s['DeliveryMethod'] as String?)
5882+
?.trim()
5883+
.toLowerCase();
5884+
final location = s['IsExternal'] == true
5885+
? 'External'
5886+
: (deliveryMethod == 'embed' ? 'Embedded' : 'Internal');
5887+
return '$subtitleType · $location';
5888+
})();
5889+
return TrackOption(
5890+
label: '$trackNumber - $label',
5891+
subtitle: subtitle.isNotEmpty ? subtitle : null,
5892+
scrollLabel: true,
5893+
scrollSubtitle: true,
5894+
);
5895+
}),
5896+
if (canDownloadRemote)
5897+
TrackOption(
5898+
label: l10n.downloadSubtitlesLabel,
5899+
subtitle: l10n.searchOpenSubtitlesPlugin,
5900+
),
5901+
];
59015902

5902-
final int? selectedIndex;
5903-
if (audio) {
5904-
final idx = currentStreamIndex != null
5905-
? streams.indexWhere((s) => s['Index'] == currentStreamIndex)
5906-
: -1;
5907-
selectedIndex = idx >= 0 ? idx : null;
5908-
} else {
5909-
if (isSubsOff || (currentStreamIndex == null && streams.isNotEmpty)) {
5910-
selectedIndex = 0;
5911-
} else if (currentStreamIndex != null) {
5912-
final idx = optionStreams.indexWhere(
5913-
(s) => s['Index'] == currentStreamIndex,
5914-
);
5915-
selectedIndex = idx >= 0 ? idx + 1 : null;
5903+
final int? selectedIndex;
5904+
if (audio) {
5905+
final idx = currentStreamIndex != null
5906+
? streams.indexWhere((s) => s['Index'] == currentStreamIndex)
5907+
: -1;
5908+
selectedIndex = idx >= 0 ? idx : null;
59165909
} else {
5917-
selectedIndex = null;
5910+
if (isSubsOff || (currentStreamIndex == null && streams.isNotEmpty)) {
5911+
selectedIndex = 0;
5912+
} else if (currentStreamIndex != null) {
5913+
final idx = optionStreams.indexWhere(
5914+
(s) => s['Index'] == currentStreamIndex,
5915+
);
5916+
selectedIndex = idx >= 0 ? idx + 1 : null;
5917+
} else {
5918+
selectedIndex = null;
5919+
}
59185920
}
5919-
}
59205921

5921-
unawaited(() async {
5922+
if (!mounted) return;
5923+
59225924
final result = await TrackSelectorDialog.show(
59235925
context,
59245926
title: audio ? l10n.audioTrack : l10n.subtitleTrack,

0 commit comments

Comments
 (0)