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