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:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user