forked from Foxyz/sync_replay_project
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
@@ -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});
|
||||
|
||||
|
||||
Reference in new issue
Block a user