1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag

Library: source switch, selectable cards with a details panel, one merged
Continue row, Latest grouped per series without re-releases, Link page with
previews and recent links, Files embedded. Player: gear popover in both
designs, progress line while controls hide, live hwdec. Settings rebuilt as
cards. State marks the host member (proto stays 9, unreleased); hosting
shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps
the outgoing view's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:58:12 +03:00
1 parent 99cda7ed04
commit d092ea371a
54 files changed
+4963 -1825

No files matched your search

+281 -10
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -12,8 +15,10 @@ 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');
@@ -23,9 +28,9 @@ enum SettingsSection {
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow). The
/// pages hold the existing settings panels; the shell owns [section] so
/// the profile card can open a page.
/// 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;
@@ -33,6 +38,8 @@ class SettingsTab extends StatelessWidget {
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,
@@ -40,6 +47,7 @@ class SettingsTab extends StatelessWidget {
this.onEditProfile,
this.updates,
this.jellyfin,
this.animeFlowMe,
});
List<Widget> _page(BuildContext context) {
@@ -64,25 +72,43 @@ class SettingsTab extends StatelessWidget {
),
),
];
case SettingsSection.network:
return const [HostDefaultsPanel(), RecentServersPanel()];
case SettingsSection.player:
return const [
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: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
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:
@@ -111,6 +137,8 @@ class SettingsTab extends StatelessWidget {
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),
@@ -168,3 +196,246 @@ class SettingsTab extends StatelessWidget {
);
}
}
/// 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),
),
),
],
);
}),
);
}
}
/// 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'),
],
),
),
SettingsPanel(
title: 'Sounds',
child: line('No sounds yet', 'The app is silent; only the video makes noise'),
),
],
);
}
}