Files
sync_replay_project/apps/desktop/lib/src/shell/settings_tab.dart
T
foxyzandClaude Opus 5.5 745717a4a0 yt-dlp for page links in every Link mode; app icon and Linux launcher entry
yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and
the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are
probed: direct files stay links, pages are resolved for title, uploader,
duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the
managed copy. I pass it on / the server fetches it: the best single-file
format, re-resolved once when its URL expires. Relay names never split
the /r/ path; viewers of server-fetched pages wait for the relay to go
live. No wire change.

Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the
Windows .ico). GTK app id moe.senkochan.watch_together; release builds
install their .desktop file and icons into ~/.local/share so Wayland
shows the icon; the window icon is set for X11. README shows the icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:21:22 +03:00

595 lines
21 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show YtDlp;
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
/// The Settings tab's pages, in nav order.
enum SettingsSection {
profile(Icons.person_outline, 'Profile'),
network(Icons.hub_outlined, 'Party & network'),
player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
notifications(Icons.notifications_none, 'Notifications & sounds'),
updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About');
final IconData icon;
final String label;
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow), each
/// page a column of cards; the shell owns [section] so the profile card
/// can open a page.
class SettingsTab extends StatelessWidget {
final SettingsSection section;
final ValueChanged<SettingsSection> onSection;
final VoidCallback? onEditProfile;
final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe;
const SettingsTab({
super.key,
required this.section,
required this.onSection,
this.onEditProfile,
this.updates,
this.jellyfin,
this.animeFlowMe,
});
List<Widget> _page(BuildContext context) {
switch (section) {
case SettingsSection.profile:
return [
SettingsPanel(
title: 'You',
child: SignalBuilder(
builder: (context) {
final me = selfAvatarInfo();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
title: Text(me.name == '?' ? 'No name yet' : me.name),
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
trailing: onEditProfile == null
? null
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
);
},
),
),
];
case SettingsSection.network:
return [
const HostDefaultsPanel(),
const RecentServersPanel(),
SettingsPanel(
title: 'Watching',
child: SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('unready-on-swap'),
contentPadding: EdgeInsets.zero,
title: const Text('Unready when the video changes'),
subtitle: const Text('Otherwise you stay ready and the next video starts once everyone ready has buffered'),
value: AppSettings.instance.unreadyOnSwap.value,
onChanged: AppSettings.instance.setUnreadyOnSwap,
),
),
),
];
case SettingsSection.player:
return [
const SettingsHintCard(
icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
),
SettingsPanel(
title: 'Player',
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: accounts),
),
AnimeFlowCard(
me: animeFlowMe ??
() async {
final client = AnimeFlowClient.fromAccounts(accounts);
try {
return await client.me();
} finally {
client.close();
}
},
),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
];
case SettingsSection.notifications:
return const [NotificationsInfo()];
case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about:
return [AboutPanel(updates: updates)];
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (final s in SettingsSection.values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
key: ValueKey('settings-${s.name}'),
avatar: Icon(s.icon, size: 18),
showCheckmark: false,
label: Text(s.label),
selected: section == s,
onSelected: (_) => onSection(s),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (final s in SettingsSection.values)
ListTile(
key: ValueKey('settings-${s.name}'),
selected: section == s,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: Icon(s.icon, size: 20),
title: Text(s.label),
onTap: () => onSection(s),
),
],
),
);
final page = fadeThrough(
KeyedSubtree(
key: ValueKey(section),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14),
..._page(context),
],
),
),
),
],
),
),
);
return narrow
? Column(children: [nav, Expanded(child: page)])
: Row(children: [nav, Expanded(child: page)]);
},
);
}
}
/// Party & network: what the Host card starts with.
class HostDefaultsPanel extends StatefulWidget {
const HostDefaultsPanel({super.key});
@override
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
}
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
final _form = SessionState.instance.lastForm;
late final _port = TextEditingController(text: _form.hostPort);
late final _secret = TextEditingController(text: _form.hostSecret);
bool _show = false;
@override
void dispose() {
_port.dispose();
_secret.dispose();
super.dispose();
}
static String? portProblem(String v) {
final n = int.tryParse(v.trim());
return n == null || n < 1 || n > 65535 ? 'A number from 1 to 65535' : null;
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
} catch (e) {
debugPrint('save host defaults failed: $e');
}
}
@override
Widget build(BuildContext context) {
return SettingsPanel(
title: 'Hosting',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingRow(
label: 'Default port',
help: 'Where your server listens when you host; friends dial it',
control: SizedBox(
width: 120,
child: TextField(
key: const ValueKey('host-port'),
controller: _port,
keyboardType: TextInputType.number,
decoration: InputDecoration(isDense: true, errorText: portProblem(_port.text)),
onChanged: (v) {
setState(() {});
if (portProblem(v) != null) return;
_form.hostPort = v.trim();
_save();
},
),
),
),
SettingRow(
label: 'Password',
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
control: SizedBox(
width: 220,
child: TextField(
key: const ValueKey('host-secret'),
controller: _secret,
obscureText: !_show,
decoration: InputDecoration(
isDense: true,
hintText: 'none',
suffixIcon: IconButton(
tooltip: _show ? 'Hide' : 'Show',
icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18),
onPressed: () => setState(() => _show = !_show),
),
),
onChanged: (v) {
_form.hostSecret = v;
_save();
},
),
),
),
],
),
);
}
}
/// Party & network: the servers Join offers in one click.
class RecentServersPanel extends StatelessWidget {
const RecentServersPanel({super.key});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'Recent servers',
child: SignalBuilder(builder: (context) {
final list = session.recents.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join and Host offer these in one click; remove the ones you no longer use',
style: TextStyle(color: muted, fontSize: 12)),
if (list.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('None yet.', style: TextStyle(color: muted)),
),
for (final r in list)
ListTile(
key: ValueKey('recent-${r.key}'),
contentPadding: EdgeInsets.zero,
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () => session.forgetServer(r),
),
),
],
);
}),
);
}
}
/// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error.
class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s.
final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@override
State<YtDlpSettings> createState() => _YtDlpSettingsState();
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return FutureBuilder<YtDlp>(
future: _y,
builder: (context, snap) {
final y = snap.data;
if (y == null) return const SizedBox.shrink();
return StreamBuilder<void>(
stream: y.changes,
builder: (context, _) {
final err = y.lastError;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.travel_explore),
title: Text(y.busy
? 'Downloading…'
: y.version == null
? 'Not downloaded yet'
: 'Version ${y.version}'),
subtitle: err == null
? null
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
trailing: TextButton(
onPressed: y.busy
? null
: () => y.path == null ? y.ensure() : y.update(force: true),
child: const Text('Update now'),
),
),
Text(
'Plays links to web pages (YouTube, X, …) in all three link modes. '
'Downloaded and kept up to date by the app; checked once a day.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
);
},
);
},
);
}
}
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not.
class AnimeFlowCard extends StatefulWidget {
final Future<AnimeFlowMe> Function() me;
const AnimeFlowCard({super.key, required this.me});
@override
State<AnimeFlowCard> createState() => _AnimeFlowCardState();
}
class _AnimeFlowCardState extends State<AnimeFlowCard> {
late Future<AnimeFlowMe> _me = widget.me();
static String scopeText(Set<AnimeFlowScope> scopes) => [
if (scopes.contains(AnimeFlowScope.view)) 'can view',
if (scopes.contains(AnimeFlowScope.request)) 'request',
if (scopes.contains(AnimeFlowScope.manage)) 'approve',
].join(', ');
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'AnimeFlow',
child: FutureBuilder<AnimeFlowMe>(
future: _me,
builder: (context, snap) {
final String status;
final Color color;
if (snap.connectionState != ConnectionState.done) {
status = 'checking…';
color = muted;
} else if (snap.hasError) {
final e = snap.error;
status = e is AnimeFlowException ? e.detail : '$e';
color = Theme.of(context).colorScheme.error;
} else {
final me = snap.data!;
final scopes = scopeText(me.scopes);
status = ['connected as ${me.name.isEmpty ? me.userId : me.name}', if (scopes.isNotEmpty) scopes]
.join(' · ');
color = Colors.greenAccent.shade400;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.downloading_outlined),
title: Text(Uri.parse(animeFlowDefaultBase).host),
subtitle: Text(status, key: const ValueKey('animeflow-status'), style: TextStyle(color: color)),
trailing: TextButton(
onPressed: snap.connectionState == ConnectionState.done
? () => setState(() => _me = widget.me())
: null,
child: const Text('Check again'),
),
),
Text(
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests. '
'Signs in with your Jellyfin account on the same domain.',
style: TextStyle(color: muted, fontSize: 12),
),
],
);
},
),
);
}
}
/// What pops up and what makes a sound. No switches: there is nothing to
/// choose yet.
class NotificationsInfo extends StatelessWidget {
const NotificationsInfo({super.key});
@override
Widget build(BuildContext context) {
final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12);
Widget line(String title, String help) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(title), Text(help, style: muted)],
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingsPanel(
title: 'Pop-ups',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line('Joins, leaves, kicks, video changes and chat',
'Pop up bottom left for 5 s, with the person\'s picture'),
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
],
),
),
const SettingsPanel(title: 'Sounds', child: SoundSettings()),
],
);
}
}
/// Volume, then every sound with a preview button and its own switch.
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) {
final sfx = SessionState.instance.sfx;
if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2));
}
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final scheme = Theme.of(context).colorScheme;
final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return SignalBuilder(builder: (context) {
final volume = s.soundVolume.value;
final off = s.soundsOff.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined,
color: scheme.onSurfaceVariant),
Expanded(
child: Slider(
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
],
),
Text('Soft cues for people and readiness; the video itself covers play, pause and the end. '
'Letting go of the slider plays the ready sound.', style: muted),
const SizedBox(height: 8),
for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(name), Text(help, style: muted)],
),
),
Switch(
key: ValueKey('sound-on-${kind.name}'),
value: !off.contains(kind.name),
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
),
],
),
),
],
);
});
}
}