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

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
@@ -41,7 +42,8 @@ class AboutPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
return SettingsPanel(
title: 'About',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1,34 +1,25 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart';
import '../../jellyfin/browse_scope.dart' show JellyfinSelection;
import '../../widgets/media_input.dart';
import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
/// The Library's sources.
enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the
/// Library tab) so it survives rebuilds and tab switches.
/// State of the Library tab, owned by the shell so it survives rebuilds
/// and tab switches: the source shown, the link field and its relay mode,
/// the selected Jellyfin card (details panel) and the last Jellyfin pick.
class MediaPickerController extends ChangeNotifier {
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_jellyfin = jellyfin,
_source = source ??
(linkUrl.isNotEmpty
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
_selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId),
_source = source ?? MediaKind.jellyfin {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
@@ -36,8 +27,9 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link;
final TextEditingController file;
MediaKind _source;
String? _notice;
LinkRelay _linkRelay = LinkRelay.direct;
JellyfinMedia? _jellyfin;
JellyfinSelection? _selected;
LinkRelay get linkRelay => _linkRelay;
set linkRelay(LinkRelay v) {
@@ -45,17 +37,23 @@ class MediaPickerController extends ChangeNotifier {
_linkRelay = v;
notifyListeners();
}
JellyfinMedia? _jellyfin;
/// The Jellyfin item picked in the browser (kept while another source
/// is shown).
/// The last Jellyfin item picked (restored from the last put-up).
JellyfinMedia? get jellyfin => _jellyfin;
/// Picks a Jellyfin item and switches to the Jellyfin source.
/// Picks a Jellyfin item: selects its card and switches to Jellyfin.
void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media;
_selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId);
_source = MediaKind.jellyfin;
_notice = null;
notifyListeners();
}
/// The selected Jellyfin card (details panel open), or null.
JellyfinSelection? get selected => _selected;
void select(JellyfinSelection? s) {
if (s == _selected && s?.item == _selected?.item) return;
_selected = s;
notifyListeners();
}
@@ -63,18 +61,10 @@ class MediaPickerController extends ChangeNotifier {
set source(MediaKind v) {
if (v == _source) return;
_source = v;
_notice = null;
notifyListeners();
}
/// One-line hint under the fields (e.g. the file dialog failed).
String? get notice => _notice;
set notice(String? v) {
_notice = v;
notifyListeners();
}
/// What the visible source has selected; null = nothing.
/// What the Link or File source holds; for Jellyfin the last pick.
PickedMedia? get selection {
switch (_source) {
case MediaKind.link:
@@ -107,292 +97,3 @@ class MediaPickerController extends ChangeNotifier {
super.dispose();
}
}
/// Source segments (Jellyfin / Link / File) and the matching input.
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<PickedMedia?>? onChanged;
/// Enter in a field.
final VoidCallback? onSubmit;
final VoidCallback? onOpenIntegrations;
final bool enabled;
final bool autofocus;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded;
final bool expanded;
/// Where the Jellyfin browser opens instead of its remembered location.
final BrowseStart? jellyfinStart;
/// False hides the File source.
final bool allowFile;
/// Shown under the Link field instead of the default help.
final String? linkHelp;
/// Shown under the File field instead of the default help.
final String? fileHelp;
/// Offer the [LinkRelay] choice under the Link field (in a session).
final bool linkRelayChoice;
const MediaPicker({
super.key,
this.jellyfinStart,
this.allowFile = true,
this.linkHelp,
this.fileHelp,
this.linkRelayChoice = false,
required this.controller,
this.onChanged,
this.onSubmit,
this.onOpenIntegrations,
this.enabled = true,
this.autofocus = false,
this.jellyfin,
this.onToggleExpanded,
this.expanded = false,
});
@override
State<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
MediaPickerController get _c => widget.controller;
@override
void initState() {
super.initState();
_c.addListener(_changed);
}
@override
void didUpdateWidget(MediaPicker old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_changed);
widget.controller.addListener(_changed);
}
}
@override
void dispose() {
_c.removeListener(_changed);
super.dispose();
}
void _changed() {
widget.onChanged?.call(_c.selection);
if (mounted) setState(() {});
}
Future<String?> _clipboard() async {
try {
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
} catch (_) {
return null;
}
}
Future<void> _pasteAnywhere() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.pasteText(text);
}
Future<void> _pasteLink() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.link.text = text;
}
Future<void> _browse() async {
try {
final files = await FilePicker.pickFiles(
dialogTitle: 'Pick media file',
type: FileType.custom,
allowedExtensions: videoExtensions,
);
final path = files.isEmpty ? null : files.first.path;
if (path != null) {
_c.file.text = path;
_c.notice = null;
}
} catch (e) {
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
}
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
return KeyEventResult.ignored;
}
// A focused text field handles its own paste.
final inField = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
if (inField) return KeyEventResult.ignored;
_pasteAnywhere();
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
final source = _c.source;
final Widget content = switch (source) {
MediaKind.jellyfin => JellyfinPickerSource(
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
selected: _c.jellyfin,
onPick: _c.pickJellyfin,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.jellyfinStart,
),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
controller: _c.link,
autofocus: widget.autofocus,
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
keyboardType: TextInputType.url,
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _pasteLink,
icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'),
),
help: widget.linkRelayChoice
? _linkRelayHelp(_c.linkRelay)
: widget.linkHelp ??
'A direct http(s) link to a video file; everyone streams it from there. '
'Jellyfin links are shared as the item, without your sign-in.',
notice: _c.notice,
extra: widget.linkRelayChoice
? SegmentedButton<LinkRelay>(
key: const ValueKey('link-relay'),
showSelectedIcon: false,
segments: const [
ButtonSegment(value: LinkRelay.direct, label: Text('Everyone fetches it')),
ButtonSegment(value: LinkRelay.server, label: Text('Server fetches it')),
ButtonSegment(value: LinkRelay.me, label: Text('Through me')),
],
selected: {_c.linkRelay},
onSelectionChanged: (s) => _c.linkRelay = s.first,
)
: null,
),
MediaKind.file => _FieldRow(
key: const ValueKey('media-file'),
field: TextField(
controller: _c.file,
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse'),
),
help: widget.fileHelp ??
'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
};
final segments = Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: [
const ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
const ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
].where((s) => widget.allowFile || s.value != MediaKind.file).toList(),
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
);
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
// The browser fills the height it is given; in an unbounded
// (scrolling) parent it gets a fixed one.
child: LayoutBuilder(builder: (context, box) {
final fill = source == MediaKind.jellyfin;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
segments,
const SizedBox(height: kGap),
if (!fill)
content
else if (box.hasBoundedHeight)
Expanded(child: content)
else
SizedBox(height: jellyfinPickerHeight, child: content),
],
);
}),
),
);
}
}
/// Height of the Jellyfin browser when the picker sits in a scroll view.
const jellyfinPickerHeight = 640.0;
class _FieldRow extends StatelessWidget {
final Widget field;
final Widget button;
final String help;
final String? notice;
/// Under the field, above the help (e.g. the link relay choice).
final Widget? extra;
const _FieldRow(
{super.key, required this.field, required this.button, required this.help, this.notice, this.extra});
@override
Widget build(BuildContext context) {
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: field),
const SizedBox(width: 12),
button,
],
),
if (extra != null) ...[const SizedBox(height: 10), extra!],
const SizedBox(height: 8),
Text(notice ?? help, style: small),
],
),
),
);
}
}
String _linkRelayHelp(LinkRelay r) => switch (r) {
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
'without your sign-in.',
LinkRelay.server => 'The server downloads it once and streams it to everyone: for links that are '
'slow or blocked for some. The server must be able to reach the link; it is not shown to anyone.',
LinkRelay.me => 'This computer downloads it and passes it on through the server: for links only you '
'can open. Keep the app open while it plays; the link is not shown to anyone.',
};
@@ -2,8 +2,6 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../playback/playback_controller.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'ui.dart';
@@ -26,7 +24,8 @@ const sessionShortcuts = [
('Esc', 'Close the panel or fullscreen, else ask to leave'),
];
/// One titled panel of the Settings tab.
/// One titled card of the Settings tab (rounded Material, so list tiles
/// paint their ripple on it).
class SettingsPanel extends StatelessWidget {
final String title;
final Widget child;
@@ -36,76 +35,56 @@ class SettingsPanel extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [SectionLabel(title), child],
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
child,
],
),
),
),
);
}
}
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();
}
/// A tinted hint card at the top of a Settings page.
class SettingsHintCard extends StatelessWidget {
final IconData icon;
final String text;
const SettingsHintCard({super.key, required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final mode = _s.hwdecMode.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: kGap),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Text('Hardware decoding'),
const SizedBox(height: 4),
const Text(
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
'shows glitches or a black picture.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
SegmentedButton<HwdecMode>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: HwdecMode.auto, label: Text('Auto')),
ButtonSegment(value: HwdecMode.off, label: Text('Off')),
ButtonSegment(value: HwdecMode.custom, label: Text('Custom')),
],
selected: {mode},
onSelectionChanged: (s) => _s.setHwdecMode(s.first),
),
if (mode == HwdecMode.custom)
Padding(
padding: const EdgeInsets.only(top: 12),
child: TextField(
controller: _custom,
decoration: fieldDecoration('mpv hwdec value',
hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'),
onChanged: _s.setHwdecCustom,
),
),
Icon(icon, color: scheme.primary),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
);
});
),
);
}
}
class ShaderSettings extends StatelessWidget {
const ShaderSettings({super.key});
/// The .glsl files of the "Custom shaders" preset, in mpv order.
class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key});
Future<void> _addFiles() async {
final List<PlatformFile> files;
@@ -130,65 +109,18 @@ class ShaderSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant);
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final custom = s.customShaders.value;
final mode = PlaybackController.lastRenderMode.value;
const hint = TextStyle(fontSize: 12, color: Colors.grey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Anime4K upscales and sharpens anime on your GPU, for you only. '
'Mode A suits 1080p sources, B 720p, C 480p; A+A, B+B and C+A add '
'a second pass. HQ is for strong GPUs (GTX 1080 and up), Fast for '
'weaker ones. Costs GPU time every frame and only works with '
'hardware video rendering. In the player: the ✨ button or '
'Ctrl+1…6, Ctrl+0 for off; changes apply live and are saved here.',
style: hint,
),
if (mode == RenderMode.software || mode == RenderMode.softwareGl)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
mode == RenderMode.software
? 'The last session used software rendering: shaders had no effect there.'
: 'The last session rendered OpenGL on the CPU: shaders will be slow.',
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
),
const SizedBox(height: 12),
Row(
children: [
const Text('Shaders'),
const SizedBox(width: 16),
DropdownButton<ShaderPreset>(
value: preset,
onChanged: (p) {
if (p != null) s.setShaderPreset(p);
},
items: [
for (final p in ShaderPreset.values)
DropdownMenuItem(value: p, child: Text(p.label)),
],
Expanded(
child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.',
style: muted),
),
const Spacer(),
SegmentedButton<ShaderQuality>(
showSelectedIcon: false,
segments: [
for (final q in ShaderQuality.values)
ButtonSegment(value: q, label: Text(q.label)),
],
selected: {quality},
onSelectionChanged: (q) => s.setShaderQuality(q.first),
),
],
),
const SizedBox(height: 12),
Row(
children: [
const Expanded(child: Text('Custom shaders (run in this order)')),
TextButton.icon(
onPressed: _addFiles,
icon: const Icon(Icons.add, size: 18),
@@ -197,7 +129,7 @@ class ShaderSettings extends StatelessWidget {
],
),
if (custom.isEmpty)
const Text('None picked.', style: hint)
Text('None picked.', style: muted)
else
for (final (i, path) in custom.indexed)
Row(
@@ -205,15 +137,13 @@ class ShaderSettings extends StatelessWidget {
Expanded(
child: Tooltip(
message: path,
child: Text(path.split(RegExp(r'[/\\]')).last,
overflow: TextOverflow.ellipsis),
child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis),
),
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () =>
s.setCustomShaders([...custom]..removeAt(i)),
onPressed: () => s.setCustomShaders([...custom]..removeAt(i)),
),
],
),
@@ -264,8 +194,10 @@ class UpdateSettings extends StatelessWidget {
const SizedBox(height: 8),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero,
title: const Text('Check automatically on start'),
title: const Text('Check for updates on start'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates,
),
@@ -275,26 +207,6 @@ class UpdateSettings extends StatelessWidget {
}
}
class MpvControlsSetting extends StatelessWidget {
const MpvControlsSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('mpv-controls'),
contentPadding: EdgeInsets.zero,
title: const Text('Experimental: mpv-style controls'),
subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with '
'chapters and the cached range, flat buttons and menus. Applies right away.'),
value: s.mpvControls.value,
onChanged: s.setMpvControls,
),
);
}
}
class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key});
@@ -306,8 +218,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'),
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it on their screen.'),
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it'),
value: s.autoAdvance.value,
onChanged: s.setAutoAdvance,
),
@@ -315,6 +227,26 @@ class AutoAdvanceSetting extends StatelessWidget {
}
}
class HideReReleasesSetting extends StatelessWidget {
const HideReReleasesSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('hide-re-releases'),
contentPadding: EdgeInsets.zero,
title: const Text('Hide re-releases in Latest'),
subtitle: const Text('Skips episodes added more than 30 days after they aired '
'(re-uploads, upgrades); also in each Latest row\'s ⋮ menu'),
value: s.hideReReleases.value,
onChanged: s.setHideReReleases,
),
);
}
}
class ShortcutList extends StatelessWidget {
const ShortcutList({super.key});