fix(player): keep hidden and cycled subtitles off in the next episode

Episode navigation carries the subtitle choice this screen has committed, so
a way of turning subtitles off that the screen never sees is undone by the
next episode.

ExoPlayer has no renderer-level visibility switch, so the player's hide
toggle is emulated by deselecting the track. That emulation lasted until the
next selection: the automatic pass after an episode change put subtitles
straight back on screen while the toggle still read "hidden", and un-hiding
then restored a track id belonging to the episode that had already ended.
Hiding is now sticky across media opens the way mpv's global sub-visibility
is, selections made while hidden become what un-hiding restores, and the
toggle no longer refuses to restore because the hidden track reads as Off.

Cycling subtitles over the native track list — downloads, and items whose
server exposes no subtitle rows — went straight to the track manager, which
owns the player selection and the server write-back but not the committed
choice. The screen records the cycled track now.
This commit is contained in:
edde746
2026-08-03 17:07:14 +02:00
parent e7aa1e4782
commit 2b4875d389
6 changed files with 245 additions and 21 deletions
+39 -8
View File
@@ -28,6 +28,13 @@ class PlayerAndroid extends PlayerBase {
bool get usingMpvFallback => _usingMpvFallback; bool get usingMpvFallback => _usingMpvFallback;
/// Subtitles are hidden through the player's visibility toggle. Sticky
/// across media opens, like mpv's global `sub-visibility`, so an episode
/// change cannot put them back on screen.
bool _subtitlesHidden = false;
/// Track that un-hiding restores: whatever was selected when subtitles were
/// hidden, then whatever was selected for the current media while hidden.
String? _hiddenSubtitleTrackId; String? _hiddenSubtitleTrackId;
@override @override
@@ -248,14 +255,36 @@ class PlayerAndroid extends PlayerBase {
await invoke('selectAudioTrack', {'trackId': track.id}); await invoke('selectAudioTrack', {'trackId': track.id});
} }
/// ExoPlayer has no renderer-level subtitle visibility switch, so hiding is
/// implemented as deselection (see [setProperty]'s `sub-visibility` case).
/// A selection arriving while subtitles are hidden — the automatic pass
/// after an episode change, or a manual pick — becomes what un-hiding
/// restores instead of putting subtitles back on screen, matching how mpv's
/// global `sub-visibility` keeps hiding across files (#1779).
@override @override
Future<void> selectSubtitleTrack(SubtitleTrack track) async { Future<void> selectSubtitleTrack(SubtitleTrack track) async {
if (_subtitlesHidden) {
_hiddenSubtitleTrackId = track.id == SubtitleTrack.off.id ? null : track.id;
return _selectSubtitleTrackNatively(SubtitleTrack.off);
}
return _selectSubtitleTrackNatively(track);
}
Future<void> _selectSubtitleTrackNatively(SubtitleTrack track) async {
await invoke('selectSubtitleTrack', {'trackId': track.id}); await invoke('selectSubtitleTrack', {'trackId': track.id});
} }
/// A sidecar flagged default must not draw itself onto a hidden renderer
/// either; the selection pass that follows the add records it the same way
/// [selectSubtitleTrack] does.
@override @override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async { Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select}); await invoke('addSubtitleTrack', {
'uri': uri,
'title': title,
'language': language,
'select': select && !_subtitlesHidden,
});
} }
@override @override
@@ -307,19 +336,21 @@ class PlayerAndroid extends PlayerBase {
break; break;
case 'sub-visibility': case 'sub-visibility':
if (value == 'no') { if (value == 'no') {
if (_subtitlesHidden) break;
_subtitlesHidden = true;
final current = state.track.subtitle; final current = state.track.subtitle;
if (current != null && current.id != 'no') { _hiddenSubtitleTrackId = current != null && current.id != SubtitleTrack.off.id ? current.id : null;
_hiddenSubtitleTrackId = current.id; if (_hiddenSubtitleTrackId != null) {
await selectSubtitleTrack(SubtitleTrack.off); await _selectSubtitleTrackNatively(SubtitleTrack.off);
} }
} else { } else {
if (!_subtitlesHidden) break;
_subtitlesHidden = false;
final storedId = _hiddenSubtitleTrackId; final storedId = _hiddenSubtitleTrackId;
if (storedId != null) {
_hiddenSubtitleTrackId = null; _hiddenSubtitleTrackId = null;
final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId); final track = storedId == null ? null : state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
if (track != null) { if (track != null) {
await selectSubtitleTrack(track); await _selectSubtitleTrackNatively(track);
}
} }
} }
break; break;
@@ -109,7 +109,20 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles()); if (!_subtitleCycleDrainActive) unawaited(_drainSubtitleCycles());
return; return;
} }
_trackManager?.cycleSubtitleTrack(); _cycleSubtitleTrackNatively();
}
/// Cycle through the native track list, for playback with no source
/// catalog to advance through (downloads, and items whose server exposes no
/// subtitle rows).
///
/// The manager owns the selection and the server write-back; the committed
/// choice is this screen's, and the episode carry-over reads it, so a cycle
/// that lands on Off has to be recorded here or the next episode inherits
/// the choice this one started with.
void _cycleSubtitleTrackNatively() {
final cycled = _trackManager?.cycleSubtitleTrack();
if (cycled != null) _rememberNativeSubtitleSelection(cycled);
} }
Future<void> _drainSubtitleCycles() async { Future<void> _drainSubtitleCycles() async {
@@ -127,7 +140,7 @@ extension _VideoPlayerCompanionRemoteMethods on VideoPlayerScreenState {
if (_isOfflinePlayback || sourceTracks.isEmpty) { if (_isOfflinePlayback || sourceTracks.isEmpty) {
_pendingSubtitleCycleCount -= advances; _pendingSubtitleCycleCount -= advances;
for (var i = 0; i < advances; i++) { for (var i = 0; i < advances; i++) {
_trackManager?.cycleSubtitleTrack(); _cycleSubtitleTrackNatively();
} }
continue; continue;
} }
+6 -3
View File
@@ -385,10 +385,12 @@ class TrackManager {
// ── Track cycling (remote/keyboard shortcuts) ────────────────────── // ── Track cycling (remote/keyboard shortcuts) ──────────────────────
/// Cycle to the next subtitle track and save the preference. /// Cycle to the next subtitle track, save the preference, and return the
void cycleSubtitleTrack() { /// track now playing so the caller can record it as the committed choice.
/// Returns null when there was nothing to cycle.
SubtitleTrack? cycleSubtitleTrack() {
final tracks = player.state.tracks.subtitle.where((t) => t.id != 'auto').toList(); final tracks = player.state.tracks.subtitle.where((t) => t.id != 'auto').toList();
if (tracks.isEmpty) return; if (tracks.isEmpty) return null;
final current = player.state.track.subtitle; final current = player.state.track.subtitle;
final currentIndex = tracks.indexWhere((t) => t.id == current?.id); final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
@@ -403,6 +405,7 @@ class TrackManager {
: 'Subtitles: ${TrackLabelBuilder.subtitleLabel(title: next.title, language: next.language, codec: next.codec, forced: next.isForced, index: nextIndex).joined}'; : 'Subtitles: ${TrackLabelBuilder.subtitleLabel(title: next.title, language: next.language, codec: next.codec, forced: next.isForced, index: nextIndex).joined}';
showMessage?.call(label, duration: const Duration(seconds: 1)); showMessage?.call(label, duration: const Duration(seconds: 1));
} }
return next;
} }
/// Cycle to the next audio track and save the preference. /// Cycle to the next audio track and save the preference.
@@ -4,11 +4,19 @@ final Expando<LatestAsyncWrite<String>> _subtitleVisibilityWrites = Expando<Late
extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
void _toggleSubtitles() { void _toggleSubtitles() {
final currentTrack = widget.player.state.track.subtitle; // Restoring always works: backends without a renderer-level visibility
// No-op if no subtitle track is selected // switch hide subtitles by deselecting them, so the current track reads
if (currentTrack == null || currentTrack.id == 'no') return; // as Off while hidden and a selection check would trap the toggle.
if (!_subtitlesVisible) {
_setSubtitleVisibility(true);
return;
}
_setSubtitleVisibility(!_subtitlesVisible); final currentTrack = widget.player.state.track.subtitle;
// Nothing to hide when no subtitle track is selected.
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
_setSubtitleVisibility(false);
} }
void _onSubtitleTrackChanged(SubtitleTrack track) { void _onSubtitleTrackChanged(SubtitleTrack track) {
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/mock_player_channels.dart';
import '../test_helpers/prefs.dart';
/// ExoPlayer has no renderer-level subtitle visibility switch, so the player's
/// hide toggle is emulated by deselecting the track. That emulation used to be
/// per-selection: the next automatic selection — an episode advance carrying
/// the previous episode's choice — put subtitles straight back on screen while
/// the toggle still read "hidden", and un-hiding then restored a track id from
/// the episode that had already ended (#1779). mpv keeps `sub-visibility`
/// across files; this pins the same behaviour on ExoPlayer.
Future<void> _withPlayer(Future<void> Function(PlayerAndroid player, _PlayerHarness harness) body) async {
final harness = _PlayerHarness();
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: harness.handle,
testBody: () async {
final player = PlayerAndroid();
try {
// What actually drives native initialize, and with it the property
// observations the track list arrives through.
await player.requestAudioFocus();
await body(player, harness);
} finally {
await player.dispose();
}
},
);
}
class _PlayerHarness {
final Map<String, int> observations = {};
final List<String> subtitleSelections = [];
Future<Object?> handle(MethodCall call) async {
switch (call.method) {
case 'initialize':
return true;
case 'observeProperty':
final arguments = call.arguments as Map;
observations[arguments['name'] as String] = arguments['id'] as int;
case 'selectSubtitleTrack':
subtitleSelections.add((call.arguments as Map)['trackId'] as String);
}
return null;
}
Future<void> sendTrackList(List<Map<String, Object?>> tracks) async {
final done = Completer<void>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
'com.plezy/exo_player/events',
const StandardMethodCodec().encodeSuccessEnvelope([observations['track-list'], jsonEncode(tracks)]),
(_) => done.complete(),
);
await done.future;
await Future<void>.delayed(Duration.zero);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('hidden subtitles stay hidden when the next episode selects its own track', () async {
await _withPlayer((player, harness) async {
await harness.sendTrackList([
{'type': 'audio', 'id': '1', 'lang': 'eng', 'selected': true},
{'type': 'sub', 'id': '2', 'lang': 'eng', 'selected': true},
]);
expect(player.state.track.subtitle?.id, '2');
await player.setProperty('sub-visibility', 'no');
expect(harness.subtitleSelections, ['no']);
// Episode advance: a new track list, then the carried-over choice.
await harness.sendTrackList([
{'type': 'audio', 'id': '1', 'lang': 'eng', 'selected': true},
{'type': 'sub', 'id': '5', 'lang': 'eng'},
]);
await player.selectSubtitleTrack(const SubtitleTrack(id: '5', language: 'eng'));
expect(harness.subtitleSelections, ['no', 'no']);
});
});
test('un-hiding restores the current media selection, not the one hiding began with', () async {
await _withPlayer((player, harness) async {
await harness.sendTrackList([
{'type': 'sub', 'id': '2', 'lang': 'eng', 'selected': true},
]);
await player.setProperty('sub-visibility', 'no');
await harness.sendTrackList([
{'type': 'sub', 'id': '5', 'lang': 'eng'},
]);
await player.selectSubtitleTrack(const SubtitleTrack(id: '5', language: 'eng'));
await player.setProperty('sub-visibility', 'yes');
expect(harness.subtitleSelections.last, '5');
});
});
test('an explicit off while hidden leaves nothing to restore', () async {
await _withPlayer((player, harness) async {
await harness.sendTrackList([
{'type': 'sub', 'id': '2', 'lang': 'eng', 'selected': true},
]);
await player.setProperty('sub-visibility', 'no');
await player.selectSubtitleTrack(SubtitleTrack.off);
harness.subtitleSelections.clear();
await player.setProperty('sub-visibility', 'yes');
expect(harness.subtitleSelections, isEmpty);
});
});
test('selections pass straight through while subtitles are visible', () async {
await _withPlayer((player, harness) async {
await harness.sendTrackList([
{'type': 'sub', 'id': '2', 'lang': 'eng', 'selected': true},
{'type': 'sub', 'id': '3', 'lang': 'swe'},
]);
await player.selectSubtitleTrack(const SubtitleTrack(id: '3', language: 'swe'));
// A redundant show is a no-op rather than a replayed selection.
await player.setProperty('sub-visibility', 'yes');
expect(harness.subtitleSelections, ['3']);
});
});
}
+25 -2
View File
@@ -1362,7 +1362,7 @@ void main() {
final mgr = _make(player: player); final mgr = _make(player: player);
addTearDown(mgr.dispose); addTearDown(mgr.dispose);
mgr.cycleSubtitleTrack(); expect(mgr.cycleSubtitleTrack(), isNull);
expect(player.selectedSubtitle, isEmpty); expect(player.selectedSubtitle, isEmpty);
}); });
@@ -1371,9 +1371,32 @@ void main() {
final mgr = _make(player: player); final mgr = _make(player: player);
addTearDown(mgr.dispose); addTearDown(mgr.dispose);
mgr.cycleSubtitleTrack(); expect(mgr.cycleSubtitleTrack(), isNull);
expect(player.selectedSubtitle, isEmpty); expect(player.selectedSubtitle, isEmpty);
}); });
test('reports the track it moved to so the caller can commit the choice', () async {
await SettingsService.getInstance();
// The screen records the committed subtitle, and episode navigation
// carries that record to the next item. A cycle the screen cannot see
// would be undone by the next episode (#1779).
final player = _FakePlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack.off,
SubtitleTrack(id: '1', language: 'eng'),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: '1', language: 'eng'),
),
);
final mgr = _make(player: player);
addTearDown(mgr.dispose);
expect(mgr.cycleSubtitleTrack()?.id, SubtitleTrack.off.id);
expect(player.selectedSubtitle.map((track) => track.id), [SubtitleTrack.off.id]);
});
}); });
group('cycleAudioTrack', () { group('cycleAudioTrack', () {