Files
sync_replay_project/apps/desktop/lib/src/controls/player_settings.dart
T
foxyzandClaude Opus 5.5 25b19f2d6a Restructure step 5: one media pipeline on a player host
- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy,
  render probe, log routing, disposal order; the only media_kit user.
- MediaPipeline: plan (one pure switch over state, origin, caps,
  presence) -> resolve -> open (prepared player promoted, else fresh)
  -> ready; commands queued during a switch apply once (P15); viewers
  play the buffer and wait for a dropped source without seek loops (D6).
- Every web page resolved by our yt-dlp into an EDL with headers and
  cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no).
- D11: sign-in errors say which cookies were used and whether they hold
  any for the site.
- Relay: 256 KiB slices read as they fill, superseded pulls released
  (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only
  when someone needs it (P28).
- STARSYNC_SMOKE run and a Windows CI smoke step after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:08:54 +03:00

469 lines
16 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../playback/playback_controller.dart' show PlaybackController, RenderMode;
import '../session/session_coordinator.dart' show SessionCoordinator;
import '../shaders/shader_presets.dart';
import '../app/app_graph.dart';
import '../shell/mark_problem.dart';
import '../shell/motion.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'frame.dart';
import 'model/controls_prefs.dart';
// Everything about the player, defined once: the rows of the player
// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and
// writes [AppSettings] and applies to a running player right away.
/// One setting: a label, one line of help under it, the control at the
/// right ([below]: an extra line under the row, e.g. a text field).
class SettingRow extends StatelessWidget {
final String label;
final String? help;
final Widget control;
final Widget? below;
const SettingRow({super.key, required this.label, this.help, required this.control, this.below});
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
if (help != null) Text(help!, style: TextStyle(color: muted, fontSize: 12)),
],
),
),
const SizedBox(width: 12),
control,
],
),
?below,
],
),
);
}
}
AppSelect<T> _dropdown<T>({
required Key key,
required T value,
required List<(T, String)> items,
required ValueChanged<T> onChanged,
bool expanded = false,
}) =>
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
/// The controls' skin: Standard or Minimal. Live.
class PlayerDesignSetting extends StatelessWidget {
const PlayerDesignSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Player design',
help: 'Minimal has a thick timeline with chapters and what is downloaded yet',
control: _dropdown<ControlsSkin>(
key: const ValueKey('player-design'),
value: s.controlsSkin.value,
items: [for (final k in ControlsSkin.values) (k, k.label)],
onChanged: s.setControlsSkin,
),
),
);
}
}
/// The bar's clock: elapsed / total or the time left. Live.
class TimeDisplaySetting extends StatelessWidget {
const TimeDisplaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Time display',
help: 'Also switched by a click on the time',
control: _dropdown<TimeDisplay>(
key: const ValueKey('time-display'),
value: s.timeDisplay.value,
items: [for (final t in TimeDisplay.values) (t, t.label)],
onChanged: s.setTimeDisplay,
),
),
);
}
}
/// Normal or large bar and buttons. Live.
class ControlsSizeSetting extends StatelessWidget {
const ControlsSizeSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Controls size',
help: 'Large makes the bar and its buttons a quarter bigger',
control: _dropdown<ControlsSize>(
key: const ValueKey('controls-size'),
value: s.controlsSize.value,
items: [for (final z in ControlsSize.values) (z, z.label)],
onChanged: s.setControlsSize,
),
),
);
}
}
/// How long the controls stay up without mouse movement. Live.
class HideDelaySetting extends StatelessWidget {
const HideDelaySetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Hide the controls after',
help: 'Without mouse movement over the video',
control: _dropdown<int>(
key: const ValueKey('controls-hide'),
value: s.controlsHideSecs.value,
items: [for (final secs in controlsHideChoices) (secs, '$secs s')],
onChanged: s.setControlsHideSecs,
),
),
);
}
}
/// One optional bar button on or off (off: it is still in ⚙). Live.
class BarButtonSetting extends StatelessWidget {
final OptionalControl control;
const BarButtonSetting({super.key, required this.control});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: control.label,
help: control.help,
control: Switch(
key: ValueKey('bar-${control.name}'),
value: !s.hiddenControls.value.contains(control),
onChanged: (on) => s.setControlShown(control, on),
),
),
);
}
}
/// The 2 px line at the bottom while the controls are hidden. Live.
class ProgressLineSetting extends StatelessWidget {
const ProgressLineSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SettingRow(
label: 'Progress line when hidden',
help: 'A thin bar at the bottom of the video while the controls are hidden',
control: Switch(
key: const ValueKey('progress-line'),
value: s.progressLine.value,
onChanged: s.setProgressLine,
),
),
);
}
}
/// Saves a shader choice (also used on the next join) and puts it on the
/// running player, if any; the player announces the result.
void applyShaderChoice(SessionCoordinator coord, ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
coord.markInput();
coord.playback.applyShaders(announce: true);
}
/// Anime4K preset (or custom files) and HQ / Fast. Live.
class ShaderPresetSetting extends StatelessWidget {
/// The running player's render mode (in the player); else the last one.
final ReadonlySignal<RenderMode>? renderMode;
const ShaderPresetSetting({super.key, this.renderMode});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final hasCustom = s.customShaders.value.isNotEmpty;
final mode = (renderMode ??
context.getInheritedWidgetOfExactType<AppScope>()?.graph.ports.players.renderMode)
?.value;
final warning = switch (mode) {
RenderMode.software => 'Software rendering: shaders have no effect',
RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow',
_ => null,
};
return SettingRow(
label: 'Upscaling shader',
help: 'Anime4K sharpens anime on your GPU, only you see it. '
'A for 1080p, B for 720p, C for 480p · Ctrl+1…6, Ctrl+0 off',
control: Row(
mainAxisSize: MainAxisSize.min,
children: [
_dropdown<ShaderPreset>(
key: const ValueKey('shader-preset'),
value: preset,
items: [
for (final p in ShaderPreset.values)
if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label),
],
onChanged: (p) => applyShaderChoice(AppScope.of(context).coordinator, p, null),
),
if (preset.isAnime4k) ...[
const SizedBox(width: 8),
_dropdown<ShaderQuality>(
key: const ValueKey('shader-quality'),
value: quality,
items: [for (final q in ShaderQuality.values) (q, q.label)],
onChanged: (q) => applyShaderChoice(AppScope.of(context).coordinator, preset, q),
),
],
],
),
below: warning == null
? null
: Text(warning, style: const TextStyle(fontSize: 12, color: Colors.orange)),
);
});
}
}
/// Puts the hwdec setting on the running player (mpv switches decoders
/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start.
Future<void> applyHwdecLive(PlaybackController playback) async {
if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return;
final p = playback.current;
if (p == null) return;
try {
await p.setProperty('hwdec', AppSettings.instance.hwdec.peek());
} catch (e) {
debugPrint('live hwdec failed: $e');
}
}
/// mpv's `hwdec`: auto, off, or a custom value. Live.
class HwdecSetting extends StatefulWidget {
const HwdecSetting({super.key});
@override
State<HwdecSetting> createState() => _HwdecSettingState();
}
class _HwdecSettingState extends State<HwdecSetting> {
final _s = AppSettings.instance;
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
@override
void dispose() {
_custom.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final mode = _s.hwdecMode.value;
return SettingRow(
label: 'Hardware decoding',
help: 'Decodes video on the GPU. Turn it off if the picture glitches or stays black',
control: _dropdown<HwdecMode>(
key: const ValueKey('hwdec'),
value: mode,
items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')],
onChanged: (m) {
_s.setHwdecMode(m);
applyHwdecLive(AppScope.of(context).coordinator.playback);
},
),
below: mode != HwdecMode.custom
? null
: Padding(
padding: const EdgeInsets.only(top: 8),
child: TextField(
controller: _custom,
decoration: const InputDecoration(
isDense: true,
labelText: 'mpv hwdec value (Enter applies)',
hintText: 'e.g. vaapi, nvdec, d3d11va, auto-safe',
border: OutlineInputBorder(),
),
onChanged: _s.setHwdecCustom,
onSubmitted: (_) => applyHwdecLive(AppScope.of(context).coordinator.playback),
),
),
);
});
}
}
/// Audio or subtitle track of the running player (in the player only).
/// The pick is remembered for the next Jellyfin item.
class TrackSetting extends StatelessWidget {
final JellyfinStreamType type;
const TrackSetting({super.key, required this.type});
bool get _audio => type == JellyfinStreamType.audio;
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
return SignalBuilder(builder: (context) {
final list = [
for (final t in coord.playback.tracks.value)
if (t.type == type) t,
];
// -1: subtitles off.
final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1);
final items = <(int, String)>[
if (!_audio) (-1, 'Off'),
for (final MpvTrack t in list) (t.id, t.label),
];
return SettingRow(
label: _audio ? 'Audio' : 'Subtitles',
help: 'Remembered for the next episode',
control: SizedBox(
width: 190,
child: items.isEmpty || selected == null
? Text(items.isEmpty ? 'None' : 'Pick one…',
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end)
: _dropdown<int>(
key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'),
value: selected,
expanded: true,
items: items,
onChanged: (id) {
coord.markInput();
if (id == -1) {
coord.selectTrack(subtitlesOff: true);
} else if (_audio) {
coord.selectTrack(aid: id);
} else {
coord.selectTrack(sid: id);
}
},
),
),
);
});
}
}
/// The controls' own choices (Settings → Player → Controls).
List<Widget> controlsSettingRows() => [
const TimeDisplaySetting(),
const ControlsSizeSetting(),
const HideDelaySetting(),
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 2),
child: Text('Buttons in the bar (hidden ones stay in ⚙)', style: TextStyle(fontWeight: FontWeight.w600)),
),
for (final c in OptionalControl.values) BarButtonSetting(control: c),
];
/// The player rows of Settings → Player and the player settings card, in
/// order.
List<Widget> playerSettingRows({ReadonlySignal<RenderMode>? renderMode}) => [
const PlayerDesignSetting(),
const ProgressLineSetting(),
ShaderPresetSetting(renderMode: renderMode),
const HwdecSetting(),
];
/// The player settings card: every player setting, plus the running
/// player's tracks.
class PlayerSettingsCard extends StatelessWidget {
final ReadonlySignal<RenderMode>? renderMode;
/// Show the audio / subtitle pickers (a player is running).
final bool tracks;
const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true});
@override
Widget build(BuildContext context) {
// The app's glass, like the Up next popover and the menus.
return AppGlass(
key: const ValueKey('player-settings-card'),
child: SizedBox(
width: 420,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
const SizedBox(height: 4),
...playerSettingRows(renderMode: renderMode),
if (tracks) ...[
const Divider(),
const TrackSetting(type: JellyfinStreamType.subtitle),
const TrackSetting(type: JellyfinStreamType.audio),
],
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
'Controls layout and custom shaders: Settings → Player',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
),
const MarkProblemButton(),
],
),
],
),
),
),
);
}
}
/// Opens the player settings card over the player, near the controls'
/// right end; the controls stay up while it is open.
Future<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) =>
ControlsFrame.holdWhile(
context,
() => showPopover<void>(
context,
at: const Alignment(0.96, 0.5),
child: PlayerSettingsCard(renderMode: renderMode),
),
);