875 lines
32 KiB
Dart
875 lines
32 KiB
Dart
import 'dart:async';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
|
|
import 'package:signals/signals_flutter.dart';
|
|
import 'package:sync_relay_core/core.dart'
|
|
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies;
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
SettingsPanel(
|
|
title: 'When idle',
|
|
child: SignalBuilder(
|
|
builder: (context) => SwitchListTile(
|
|
key: const ValueKey('idle-sits-out'),
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('When idle, sit out instead of just unready'),
|
|
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
|
value: AppSettings.instance.idleSitsOut.value,
|
|
onChanged: AppSettings.instance.setIdleSitsOut,
|
|
),
|
|
),
|
|
),
|
|
];
|
|
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),
|
|
),
|
|
const SizedBox(height: 12),
|
|
YtDlpCookieSettings(cookies: y.cookies),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
|
|
/// cookies.txt / JSON export picked or dropped, filtered by site into the
|
|
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
|
|
class YtDlpCookieSettings extends StatefulWidget {
|
|
final YtDlpCookies cookies;
|
|
const YtDlpCookieSettings({super.key, required this.cookies});
|
|
|
|
@override
|
|
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
|
|
}
|
|
|
|
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
|
|
late var _source = widget.cookies.read();
|
|
String? _error;
|
|
bool _dragging = false;
|
|
|
|
void _reload([String? error]) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_source = widget.cookies.read();
|
|
_error = error;
|
|
});
|
|
}
|
|
|
|
String get _summary => switch (_source) {
|
|
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for '
|
|
'${f.sites.length <= 3 ? f.sites.join(', ') : '${f.sites.length} sites'}',
|
|
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
|
|
_ => 'No cookies: yt-dlp visits sites as a guest',
|
|
};
|
|
|
|
Future<void> _pickFile() async {
|
|
final List<PlatformFile> files;
|
|
try {
|
|
files = await FilePicker.pickFiles(
|
|
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
|
|
type: FileType.custom,
|
|
allowedExtensions: const ['txt', 'json'],
|
|
);
|
|
} catch (_) {
|
|
_reload('file dialog unavailable — try again');
|
|
return;
|
|
}
|
|
final path = files.firstOrNull?.path;
|
|
if (path != null) await _importFile(path);
|
|
}
|
|
|
|
Future<void> _importFile(String path) async {
|
|
final List<NetscapeCookie> all;
|
|
try {
|
|
all = parseCookies(await File(path).readAsString());
|
|
} on FormatException catch (e) {
|
|
_reload("Can't read that file: ${e.message}");
|
|
return;
|
|
} on FileSystemException catch (e) {
|
|
_reload("Can't read that file: ${e.message}");
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
// Every site in the file: an export is "my logins", not a pick list.
|
|
try {
|
|
await widget.cookies.saveFile(all);
|
|
_reload();
|
|
} catch (e) {
|
|
_reload('Could not keep the cookies: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _pickBrowser() async {
|
|
final current = _source;
|
|
final picked = await showDialog<CookieBrowser>(
|
|
context: context,
|
|
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
|
|
);
|
|
if (picked == null) return;
|
|
try {
|
|
await widget.cookies.saveBrowser(picked.browser, picked.profile);
|
|
_reload();
|
|
} catch (e) {
|
|
_reload('Could not save: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _remove() async {
|
|
try {
|
|
await widget.cookies.remove();
|
|
_reload();
|
|
} catch (e) {
|
|
_reload('Could not remove the cookies: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return DropTarget(
|
|
onDragEntered: (_) => setState(() => _dragging = true),
|
|
onDragExited: (_) => setState(() => _dragging = false),
|
|
onDragDone: (d) {
|
|
setState(() => _dragging = false);
|
|
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
|
|
if (path != null) _importFile(path);
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.cookie_outlined),
|
|
title: const Text('Cookies'),
|
|
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
|
|
),
|
|
if (_error != null)
|
|
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _pickBrowser,
|
|
icon: const Icon(Icons.public),
|
|
label: const Text('From a browser…'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _pickFile,
|
|
icon: const Icon(Icons.file_open_outlined),
|
|
label: const Text('From a file…'),
|
|
),
|
|
if (_source != null)
|
|
TextButton(
|
|
key: const ValueKey('ytdlp-cookies-remove'),
|
|
onPressed: _remove,
|
|
child: const Text('Remove cookies'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
|
|
'cookie-extension JSON export here, or pick one; all of it is kept, on this computer only. '
|
|
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
|
|
'throwaway account (Google may flag accounts used with yt-dlp).',
|
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CookieBrowserDialog extends StatefulWidget {
|
|
final CookieBrowser? initial;
|
|
const _CookieBrowserDialog({this.initial});
|
|
|
|
@override
|
|
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
|
|
}
|
|
|
|
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
|
|
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
|
|
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
|
|
|
|
@override
|
|
void dispose() {
|
|
_profile.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Cookies from a browser'),
|
|
content: SizedBox(
|
|
width: 380,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _browser,
|
|
decoration: const InputDecoration(labelText: 'Browser'),
|
|
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
|
|
onChanged: (v) => setState(() => _browser = v ?? _browser),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _profile,
|
|
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
|
|
child: const Text('Use'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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, video changes and chat',
|
|
'Pop up bottom left for 5 s, with the person\'s picture'),
|
|
line('Sat out or nudged by someone', 'Only on your end, with their 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 and the sound set, then every sound with a preview button, its
|
|
/// own set (or the overall one) 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;
|
|
final overall = s.soundPreset.value;
|
|
final per = s.soundPresetFor.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: 12),
|
|
Row(
|
|
children: [
|
|
const Expanded(child: Text('Sound set')),
|
|
DropdownButton<String>(
|
|
key: const ValueKey('sound-preset'),
|
|
value: overall,
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
s.setSoundPreset(v);
|
|
_preview(SfxKind.ready);
|
|
},
|
|
items: [
|
|
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
|
DropdownMenuItem(value: p.name, child: Text(name)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
Text(presetInfo.entries.where((e) => e.key.name == overall).firstOrNull?.value.$2 ?? '', 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)],
|
|
),
|
|
),
|
|
DropdownButton<String?>(
|
|
key: ValueKey('sound-preset-${kind.name}'),
|
|
value: per[kind.name],
|
|
underline: const SizedBox.shrink(),
|
|
style: muted,
|
|
onChanged: (v) {
|
|
s.setSoundPresetFor(kind.name, v);
|
|
if (volume > 0) _preview(kind);
|
|
},
|
|
items: [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
|
|
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
|
|
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
|
],
|
|
),
|
|
// A nudge always plays (an explicit poke): no switch,
|
|
// its place kept so the rows line up.
|
|
Visibility.maintain(
|
|
visible: kind != SfxKind.nudge,
|
|
child: Switch(
|
|
key: ValueKey('sound-on-${kind.name}'),
|
|
value: !off.contains(kind.name),
|
|
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
}
|