With the new playback setting enabled, episode advance carries no audio or subtitle preference at all, so both resolve from the streams selected on the server for each individual episode. This serves setups that curate selections server-side (e.g. Plex Auto Languages) and is independent of "Remember track selections", which keeps gating only the write-back of manual changes. close #1717
464 lines
17 KiB
Dart
464 lines
17 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../models/audio_quality_preset.dart';
|
|
import '../../models/transcode_quality_preset.dart';
|
|
import '../../mpv/player/platform/player_android.dart';
|
|
import '../../utils/quality_preset_labels.dart';
|
|
import '../../services/companion_remote/companion_remote_host_controller.dart';
|
|
import '../../services/discord_rpc_service.dart';
|
|
import '../../services/keyboard_shortcuts_service.dart';
|
|
import '../../services/settings_service.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../utils/snackbar_helper.dart';
|
|
import '../../widgets/setting_tile.dart';
|
|
import '../../widgets/settings_builder.dart';
|
|
import '../../widgets/settings_page.dart';
|
|
import '../../widgets/settings_section.dart';
|
|
import 'atmos_diagnostics_screen.dart';
|
|
import 'external_player_screen.dart';
|
|
import 'mpv_config_screen.dart';
|
|
import 'settings_utils.dart';
|
|
import 'subtitle_styling_screen.dart';
|
|
|
|
class PlaybackSettingsScreen extends StatefulWidget {
|
|
const PlaybackSettingsScreen({super.key});
|
|
|
|
@override
|
|
State<PlaybackSettingsScreen> createState() => _PlaybackSettingsScreenState();
|
|
}
|
|
|
|
class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|
KeyboardShortcutsService? _keyboardService;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (KeyboardShortcutsService.isPlatformSupported()) {
|
|
KeyboardShortcutsService.getInstance().then((s) {
|
|
if (mounted) _keyboardService = s;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMobile = PlatformDetector.isMobile(context);
|
|
|
|
// Visibility of several Player tiles is pref-reactive; hoisted here so
|
|
// group children can use plain `if`s (a SizedBox.shrink() child would
|
|
// corrupt the SettingsGroup corner shapes).
|
|
return SettingsBuilder(
|
|
prefs: const [
|
|
SettingsService.useExoPlayer,
|
|
SettingsService.matchRefreshRate,
|
|
SettingsService.matchDynamicRange,
|
|
SettingsService.matchContentFrameRate,
|
|
SettingsService.audioDownmix,
|
|
],
|
|
builder: (context) {
|
|
final svc = SettingsService.instance;
|
|
final exoActive = Platform.isAndroid && svc.read(SettingsService.useExoPlayer);
|
|
final downmixOn = svc.read(SettingsService.audioDownmix);
|
|
final showDisplaySwitchDelay =
|
|
PlatformDetector.isAppleTV() ||
|
|
(Platform.isWindows &&
|
|
(svc.read(SettingsService.matchRefreshRate) || svc.read(SettingsService.matchDynamicRange))) ||
|
|
(Platform.isAndroid && svc.read(SettingsService.matchContentFrameRate));
|
|
|
|
return SettingsPage(
|
|
title: Text(t.settings.videoPlayback),
|
|
children: [
|
|
SettingsGroup(
|
|
title: t.settings.player,
|
|
children: [
|
|
if (Platform.isAndroid) _playerBackendSelector(),
|
|
if (PlatformDetector.supportsExternalPlayers()) _externalPlayerTile(),
|
|
_hardwareDecodingTile(),
|
|
if (PlatformDetector.supportsPictureInPicture()) _autoPipTile(),
|
|
if (Platform.isAndroid) _matchContentFrameRateTile(),
|
|
if (Platform.isWindows) _matchRefreshRateTile(),
|
|
if (Platform.isWindows) _matchDynamicRangeTile(),
|
|
if (showDisplaySwitchDelay) _displaySwitchDelayTile(),
|
|
if (exoActive) _tunneledPlaybackTile(),
|
|
if (PlatformDetector.supportsAudioPassthrough()) _audioPassthroughTile(),
|
|
_audioDownmixTile(),
|
|
if (downmixOn) _downmixCenterBoostTile(),
|
|
if (downmixOn) _downmixNormalizeTile(),
|
|
if (PlatformDetector.isAppleTV()) _atmosDiagnosticsTile(),
|
|
if (exoActive) _dvConversionModeTile(),
|
|
_bufferSizeTile(),
|
|
_defaultQualityTile(),
|
|
_musicQualityTile(),
|
|
],
|
|
),
|
|
|
|
SettingsGroup(
|
|
title: t.settings.subtitlesAndConfig,
|
|
children: [
|
|
SettingNavigationTile(
|
|
icon: Symbols.subtitles_rounded,
|
|
title: t.settings.subtitleStyling,
|
|
subtitle: t.settings.subtitleStylingDescription,
|
|
destinationBuilder: (_) => const SubtitleStylingScreen(),
|
|
),
|
|
if (!exoActive) _mpvConfigTile(),
|
|
],
|
|
),
|
|
|
|
_seekAndTimingGroup(),
|
|
_behaviorGroup(context, isMobile),
|
|
_autoSkipGroup(),
|
|
const SizedBox(height: 24),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _seekAndTimingGroup() => SettingsGroup(
|
|
title: t.settings.seekAndTiming,
|
|
children: [
|
|
SettingNumberTile(
|
|
pref: SettingsService.seekTimeSmall,
|
|
icon: Symbols.replay_10_rounded,
|
|
title: t.settings.smallSkipDuration,
|
|
subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()),
|
|
labelText: t.settings.secondsLabel,
|
|
suffixText: t.settings.secondsShort,
|
|
min: 1,
|
|
max: 120,
|
|
onAfterWrite: (_) => _keyboardService?.refreshFromStorage(),
|
|
),
|
|
SettingNumberTile(
|
|
pref: SettingsService.seekTimeLarge,
|
|
icon: Symbols.replay_30_rounded,
|
|
title: t.settings.largeSkipDuration,
|
|
subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()),
|
|
labelText: t.settings.secondsLabel,
|
|
suffixText: t.settings.secondsShort,
|
|
min: 1,
|
|
max: 120,
|
|
onAfterWrite: (_) => _keyboardService?.refreshFromStorage(),
|
|
),
|
|
SettingNumberTile(
|
|
pref: SettingsService.rewindOnResume,
|
|
icon: Symbols.replay_rounded,
|
|
title: t.settings.rewindOnResume,
|
|
subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()),
|
|
labelText: t.settings.secondsLabel,
|
|
suffixText: t.settings.secondsShort,
|
|
min: 0,
|
|
max: 10,
|
|
),
|
|
SettingNumberTile(
|
|
pref: SettingsService.sleepTimerDuration,
|
|
icon: Symbols.bedtime_rounded,
|
|
title: t.settings.defaultSleepTimer,
|
|
subtitleBuilder: (v) => t.settings.minutesUnit(minutes: v.toString()),
|
|
labelText: t.settings.minutesLabel,
|
|
suffixText: t.settings.minutesShort,
|
|
min: 5,
|
|
max: 240,
|
|
),
|
|
SettingNumberTile(
|
|
pref: SettingsService.maxVolume,
|
|
icon: Symbols.volume_up_rounded,
|
|
title: t.settings.maxVolume,
|
|
subtitleBuilder: (v) => t.settings.maxVolumePercent(percent: v.toString()),
|
|
labelText: t.settings.maxVolumeDescription,
|
|
suffixText: '%',
|
|
min: 100,
|
|
max: 300,
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _behaviorGroup(BuildContext context, bool isMobile) => SettingsGroup(
|
|
title: t.settings.behavior,
|
|
children: [
|
|
if (DiscordRPCService.isAvailable)
|
|
SettingSwitchTile(
|
|
pref: SettingsService.enableDiscordRPC,
|
|
icon: Symbols.chat_rounded,
|
|
title: t.settings.discordRichPresence,
|
|
subtitle: t.settings.discordRichPresenceDescription,
|
|
onAfterWrite: (v) => DiscordRPCService.instance.setEnabled(v),
|
|
),
|
|
if (PlatformDetector.shouldActAsRemoteHost(context))
|
|
SettingSwitchTile(
|
|
pref: SettingsService.enableCompanionRemoteServer,
|
|
icon: Symbols.phone_android_rounded,
|
|
title: t.settings.companionRemoteServer,
|
|
subtitle: t.settings.companionRemoteServerDescription,
|
|
onAfterWrite: (v) => applyCompanionRemoteServerSetting(context, v),
|
|
),
|
|
SettingSwitchTile(
|
|
pref: SettingsService.rememberTrackSelections,
|
|
icon: Symbols.bookmark_rounded,
|
|
title: t.settings.rememberTrackSelections,
|
|
subtitle: t.settings.rememberTrackSelectionsDescription,
|
|
),
|
|
SettingSwitchTile(
|
|
pref: SettingsService.followServerTrackSelections,
|
|
icon: Symbols.dns_rounded,
|
|
title: t.settings.followServerTrackSelections,
|
|
subtitle: t.settings.followServerTrackSelectionsDescription,
|
|
),
|
|
SettingSwitchTile(
|
|
pref: SettingsService.showChapterMarkersOnTimeline,
|
|
icon: Symbols.bookmarks_rounded,
|
|
title: t.settings.showChapterMarkersOnTimeline,
|
|
subtitle: t.settings.showChapterMarkersOnTimelineDescription,
|
|
),
|
|
if (!isMobile)
|
|
SettingSwitchTile(
|
|
pref: SettingsService.clickVideoTogglesPlayback,
|
|
icon: Symbols.play_pause_rounded,
|
|
title: t.settings.clickVideoTogglesPlayback,
|
|
subtitle: t.settings.clickVideoTogglesPlaybackDescription,
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _autoSkipGroup() => SettingsGroup(
|
|
title: t.settings.autoSkip,
|
|
children: [
|
|
SettingSwitchTile(
|
|
pref: SettingsService.autoSkipIntro,
|
|
icon: Symbols.fast_forward_rounded,
|
|
title: t.settings.autoSkipIntro,
|
|
subtitle: t.settings.autoSkipIntroDescription,
|
|
),
|
|
SettingSwitchTile(
|
|
pref: SettingsService.autoSkipCredits,
|
|
icon: Symbols.skip_next_rounded,
|
|
title: t.settings.autoSkipCredits,
|
|
subtitle: t.settings.autoSkipCreditsDescription,
|
|
),
|
|
SettingSwitchTile(
|
|
pref: SettingsService.forceSkipMarkerFallback,
|
|
icon: Symbols.tune_rounded,
|
|
title: t.settings.forceSkipMarkerFallback,
|
|
subtitle: t.settings.forceSkipMarkerFallbackDescription,
|
|
),
|
|
SettingNumberTile(
|
|
pref: SettingsService.autoSkipDelay,
|
|
icon: Symbols.timer_rounded,
|
|
title: t.settings.autoSkipDelay,
|
|
subtitleBuilder: (v) => t.settings.autoSkipDelayDescription(seconds: v.toString()),
|
|
labelText: t.settings.secondsLabel,
|
|
suffixText: t.settings.secondsShort,
|
|
min: 1,
|
|
max: 30,
|
|
),
|
|
SettingRegexTile(
|
|
pref: SettingsService.introPattern,
|
|
icon: Symbols.match_case_rounded,
|
|
title: t.settings.introPattern,
|
|
subtitle: t.settings.introPatternDescription,
|
|
defaultValue: SettingsService.defaultIntroPattern,
|
|
),
|
|
SettingRegexTile(
|
|
pref: SettingsService.creditsPattern,
|
|
icon: Symbols.match_case_rounded,
|
|
title: t.settings.creditsPattern,
|
|
subtitle: t.settings.creditsPatternDescription,
|
|
defaultValue: SettingsService.defaultCreditsPattern,
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _playerBackendSelector() => SettingSegmentedTile<bool>(
|
|
pref: SettingsService.useExoPlayer,
|
|
icon: Symbols.play_circle_rounded,
|
|
title: t.settings.playerBackend,
|
|
segments: [
|
|
ButtonSegment(value: true, label: Text(t.settings.exoPlayer)),
|
|
ButtonSegment(value: false, label: Text(t.settings.mpv)),
|
|
],
|
|
);
|
|
|
|
Widget _externalPlayerTile() => SettingsBuilder(
|
|
prefs: [SettingsService.useExternalPlayer, SettingsService.selectedExternalPlayer],
|
|
builder: (context) {
|
|
final svc = SettingsService.instance;
|
|
final useExt = svc.read(SettingsService.useExternalPlayer);
|
|
final player = svc.read(SettingsService.selectedExternalPlayer);
|
|
return SettingNavigationTile(
|
|
icon: Symbols.open_in_new_rounded,
|
|
title: t.externalPlayer.title,
|
|
subtitle: useExt
|
|
? (player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name)
|
|
: t.externalPlayer.off,
|
|
destinationBuilder: (_) => const ExternalPlayerScreen(),
|
|
);
|
|
},
|
|
);
|
|
|
|
Widget _hardwareDecodingTile() => SettingSwitchTile(
|
|
pref: SettingsService.enableHardwareDecoding,
|
|
icon: Symbols.hardware_rounded,
|
|
title: t.settings.hardwareDecoding,
|
|
subtitle: t.settings.hardwareDecodingDescription,
|
|
);
|
|
|
|
Widget _autoPipTile() => SettingSwitchTile(
|
|
pref: SettingsService.autoPip,
|
|
icon: Symbols.picture_in_picture_alt_rounded,
|
|
title: t.settings.autoPip,
|
|
subtitle: t.settings.autoPipDescription,
|
|
);
|
|
|
|
Widget _matchContentFrameRateTile() => SettingSwitchTile(
|
|
pref: SettingsService.matchContentFrameRate,
|
|
icon: Symbols.display_settings_rounded,
|
|
title: t.settings.matchContentFrameRate,
|
|
subtitle: t.settings.matchContentFrameRateDescription,
|
|
);
|
|
|
|
Widget _matchRefreshRateTile() => SettingSwitchTile(
|
|
pref: SettingsService.matchRefreshRate,
|
|
icon: Symbols.display_settings_rounded,
|
|
title: t.settings.matchRefreshRate,
|
|
subtitle: t.settings.matchRefreshRateDescription,
|
|
);
|
|
|
|
Widget _matchDynamicRangeTile() => SettingSwitchTile(
|
|
pref: SettingsService.matchDynamicRange,
|
|
icon: Symbols.hdr_on_rounded,
|
|
title: t.settings.matchDynamicRange,
|
|
subtitle: t.settings.matchDynamicRangeDescription,
|
|
);
|
|
|
|
Widget _audioPassthroughTile() => SettingSwitchTile(
|
|
pref: SettingsService.audioPassthrough,
|
|
icon: Symbols.surround_sound_rounded,
|
|
title: t.settings.audioPassthrough,
|
|
subtitle: PlatformDetector.isAppleTV()
|
|
? t.settings.audioPassthroughDescriptionAppleTv
|
|
: t.settings.audioPassthroughDescription,
|
|
);
|
|
|
|
Widget _audioDownmixTile() => SettingSwitchTile(
|
|
pref: SettingsService.audioDownmix,
|
|
icon: Symbols.headphones_rounded,
|
|
title: t.settings.audioDownmix,
|
|
subtitle: t.settings.audioDownmixDescription,
|
|
);
|
|
|
|
Widget _downmixCenterBoostTile() => SettingNumberTile(
|
|
pref: SettingsService.downmixCenterBoost,
|
|
icon: Symbols.record_voice_over_rounded,
|
|
title: t.settings.downmixCenterBoost,
|
|
subtitleBuilder: (v) => t.settings.downmixCenterBoostValue(db: v.toString()),
|
|
labelText: t.settings.downmixCenterBoostLabel,
|
|
suffixText: t.settings.downmixCenterBoostShort,
|
|
min: 0,
|
|
max: 12,
|
|
);
|
|
|
|
Widget _downmixNormalizeTile() => SettingSwitchTile(
|
|
pref: SettingsService.audioDownmixNormalize,
|
|
icon: Symbols.graphic_eq_rounded,
|
|
title: t.settings.audioDownmixNormalize,
|
|
subtitle: t.settings.audioDownmixNormalizeDescription,
|
|
);
|
|
|
|
Widget _atmosDiagnosticsTile() => SettingNavigationTile(
|
|
icon: Symbols.spatial_audio_rounded,
|
|
title: t.settings.atmosDiagnostics,
|
|
subtitle: t.settings.atmosDiagnosticsDescription,
|
|
destinationBuilder: (_) => const AtmosDiagnosticsScreen(),
|
|
);
|
|
|
|
// Visibility for this and the three tiles below is decided by the hoisted
|
|
// SettingsBuilder in build().
|
|
Widget _displaySwitchDelayTile() => SettingNumberTile(
|
|
pref: SettingsService.displaySwitchDelay,
|
|
icon: Symbols.timer_rounded,
|
|
title: t.settings.displaySwitchDelay,
|
|
subtitleBuilder: (v) => t.settings.secondsUnit(seconds: v.toString()),
|
|
labelText: t.settings.secondsLabel,
|
|
suffixText: t.settings.secondsShort,
|
|
min: 0,
|
|
max: 10,
|
|
);
|
|
|
|
Widget _tunneledPlaybackTile() => SettingSwitchTile(
|
|
pref: SettingsService.tunneledPlayback,
|
|
icon: Symbols.tv_options_input_settings_rounded,
|
|
title: t.settings.tunneledPlayback,
|
|
subtitle: t.settings.tunneledPlaybackDescription,
|
|
);
|
|
|
|
Widget _dvConversionModeTile() => SettingSelectionTile<DvConversionModePreference>(
|
|
pref: SettingsService.dvConversionMode,
|
|
icon: Symbols.hdr_strong_rounded,
|
|
title: t.settings.dvConversionMode,
|
|
subtitleBuilder: (mode) => '${_dvConversionModeLabel(mode)} · ${t.settings.dvConversionModeDescription}',
|
|
options: DvConversionModePreference.values
|
|
.map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m)))
|
|
.toList(),
|
|
);
|
|
|
|
String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) {
|
|
DvConversionModePreference.auto => t.settings.dvConversionAuto,
|
|
DvConversionModePreference.disabled => t.settings.dvConversionNative,
|
|
DvConversionModePreference.dv81 => t.settings.dvConversionDv81,
|
|
DvConversionModePreference.hevcStrip => t.settings.dvConversionHevcStrip,
|
|
};
|
|
|
|
Widget _bufferSizeTile() {
|
|
final bufferOptions = const [0, 64, 128, 256, 512, 1024];
|
|
return SettingSelectionTile<int>(
|
|
pref: SettingsService.bufferSize,
|
|
icon: Symbols.memory_rounded,
|
|
title: t.settings.bufferSize,
|
|
subtitleBuilder: (v) => v == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: v.toString()),
|
|
options: bufferOptions
|
|
.map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB'))
|
|
.toList(),
|
|
onAfterWrite: (value) async {
|
|
if (Platform.isAndroid && value > 0) {
|
|
final heapMB = await PlayerAndroid.getHeapSize();
|
|
if (heapMB > 0 && value > heapMB ~/ 4 && mounted) {
|
|
showAppSnackBar(context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()));
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _defaultQualityTile() => SettingSelectionTile<TranscodeQualityPreset>(
|
|
pref: SettingsService.defaultQualityPreset,
|
|
icon: Symbols.high_quality_rounded,
|
|
title: t.settings.defaultQualityTitle,
|
|
subtitleBuilder: qualityPresetLabel,
|
|
options: TranscodeQualityPreset.displayOrder
|
|
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
|
|
.toList(),
|
|
);
|
|
|
|
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset>(
|
|
pref: SettingsService.musicQualityPreset,
|
|
icon: Symbols.music_note_rounded,
|
|
title: t.settings.musicQualityTitle,
|
|
subtitleBuilder: _musicQualityLabel,
|
|
options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(),
|
|
);
|
|
|
|
String _musicQualityLabel(AudioQualityPreset preset) =>
|
|
preset.isOriginal ? t.videoControls.qualityOriginal : '${preset.bitrateKbps} kbps';
|
|
|
|
Widget _mpvConfigTile() => SettingNavigationTile(
|
|
icon: Symbols.tune_rounded,
|
|
title: t.mpvConfig.title,
|
|
subtitle: t.mpvConfig.description,
|
|
destinationBuilder: (_) => const MpvConfigScreen(),
|
|
);
|
|
}
|