Shaders: Anime4K presets in-session and in Settings

Vendored Anime4K v4.0.1 GLSL (MIT), extracted to app support once per
version. Presets A/B/C/A+A/B+B/C+A in HQ and Fast per Anime4K's mpv
templates, plus custom files. Shader button in the video controls
(works in fullscreen), Ctrl+1..6 / Ctrl+0, live apply with read-back,
failures fall back to Off with a toast. Render mode detected from
mpv's GL log; warns when on the software path. Docs include the
Jellyfin client section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:45:54 +03:00
1 parent 4814bfb561
commit fb2927c094
28 files changed
+6516 -53

No files matched your search

@@ -6,6 +6,8 @@ import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../shaders/shader_assets.dart';
import '../shaders/shader_presets.dart';
import '../state/settings.dart';
import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
@@ -32,6 +34,11 @@ class PlaybackController {
Timer? _watchTimer;
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
StreamSubscription<PlayerLog>? _logSub;
// `glsl-shaders` as last set on the current player ('' = none).
String _shaders = '';
Future<void> _shaderOp = Future.value();
int _noticeId = 0;
/// Bumped for every new player; the video view keys itself on it so a
/// recreated player gets a fresh `Video` widget.
@@ -53,6 +60,14 @@ class PlaybackController {
/// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek;
/// Latest shader message for the user (the session screen toasts it).
final shaderNotice = signal<ShaderNotice?>(null);
/// How media_kit renders the current player's video (shaders need
/// [RenderMode.gpu]); [lastRenderMode] keeps it after leaving.
final renderMode = signal(RenderMode.unknown);
static final lastRenderMode = signal(RenderMode.unknown);
/// Pinned mpv properties whose read-back differs from what was set
/// (mpv ignores invalid values silently), as `name: wanted -> got`.
final List<String> pinMismatches = [];
@@ -67,10 +82,12 @@ class PlaybackController {
final hwdec = _hwdecOverride;
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
return _video = VideoController(
final video = _video = VideoController(
player,
configuration: VideoControllerConfiguration(hwdec: hwdec),
);
unawaited(_detectRenderMode(player, video));
return video;
}
// SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a
@@ -88,9 +105,16 @@ class PlaybackController {
bufferSize: maxBytes,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
// Verbose: the render-mode probe needs mpv's GL init lines.
logLevel: MPVLogLevel.v,
))
..onUserSeek = (d) => onUserSeek?.call(d);
_shaders = '';
renderMode.value = RenderMode.unknown;
shaderNotice.value = null;
_logSub = _player!.stream.log.listen(_onLog);
await _pinBufferProps();
await applyShaders();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
generation.value++;
@@ -139,6 +163,132 @@ class PlaybackController {
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
}
/// Puts the saved shader choice (Settings, Ctrl+0..6, shader button) on
/// the current player, live. A missing file or a value mpv won't take
/// turns shaders off and says why in [shaderNotice]; [announce] also
/// reports success ("Shaders: Mode A (HQ)").
Future<void> applyShaders({bool announce = false}) =>
_shaderOp = _shaderOp.then((_) => _applyShaders(announce));
Future<void> _applyShaders(bool announce) async {
final p = _player;
if (p == null) return;
final s = AppSettings.instance;
final preset = s.shaderPreset.peek();
final label = shaderLabel(preset, s.shaderQuality.peek());
String? problem;
var paths = const <String>[];
try {
paths = await shaderPathsFor(
preset, s.shaderQuality.peek(), s.customShaders.peek());
if (preset == ShaderPreset.custom && paths.isEmpty) {
problem = 'no custom shader files picked';
} else {
problem = shaderPathsProblem(paths);
}
} catch (e) {
problem = 'could not unpack the shaders ($e)';
}
if (!identical(p, _player)) return;
if (problem != null) paths = const [];
final value = joinShaderPaths(paths);
if (value != _shaders || problem != null) {
final got = await setShaderValue(value);
if (!identical(p, _player)) return;
if (problem == null && got != value) {
problem = 'mpv did not take them';
await setShaderValue('');
}
}
if (problem != null) {
_turnShadersOff('$label failed: $problem — turned off');
} else if (announce) {
_notice('Shaders: $label${_softwareHint(paths)}');
}
}
/// Sets mpv `glsl-shaders` on the current player and returns the
/// read-back (null on error).
@visibleForTesting
Future<String?> setShaderValue(String value) async {
try {
final dynamic platform = player.platform;
await platform?.setProperty('glsl-shaders', value);
final got = '${await platform?.getProperty('glsl-shaders')}';
_shaders = got;
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"');
return got;
} catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st');
return null;
}
}
void _turnShadersOff(String message) {
AppSettings.instance.setShaderPreset(ShaderPreset.off);
_notice(message);
}
void _notice(String text) {
shaderNotice.value = ShaderNotice(++_noticeId, text);
}
String _softwareHint(List<String> paths) => paths.isEmpty
? ''
: switch (renderMode.peek()) {
RenderMode.software => ' (no effect: video uses software rendering)',
RenderMode.softwareGl => ' (likely slow: OpenGL runs on the CPU)',
_ => '',
};
// mpv logs from the render context: GL init lines mean GPU rendering;
// an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it).
void _onLog(PlayerLog log) {
if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl);
} else if (log.text.startsWith('GL_VERSION') &&
renderMode.peek() == RenderMode.unknown) {
_setRenderMode(RenderMode.gpu);
} else if ((log.level == 'error' || log.level == 'fatal') &&
_shaders.isNotEmpty) {
debugPrint('shader error: ${log.text}');
final p = _player;
_shaders = '';
_shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return;
await setShaderValue('');
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
});
}
}
void _setRenderMode(RenderMode mode) {
renderMode.value = mode;
lastRenderMode.value = mode;
}
// media_kit's Linux/Windows video output creates mpv's render context
// while the controller is created: OpenGL when it can, else the
// software renderer, which never logs GL init (and ignores shaders).
Future<void> _detectRenderMode(Player p, VideoController video) async {
try {
await video.platform.future;
} catch (_) {
return;
}
await Future.delayed(const Duration(milliseconds: 1500));
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
return;
}
_setRenderMode(RenderMode.software);
debugPrint('video output: software rendering (no GL init logged)');
if (_shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering');
}
}
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
/// off-thread); null without video.
Future<int?> _sourceFrame(Player p) async {
@@ -267,6 +417,8 @@ class PlaybackController {
if (p == null) return;
_watchTimer?.cancel();
_watchTimer = null;
await _logSub?.cancel();
_logSub = null;
_player = null;
_video = null;
alive.value = false;
@@ -300,6 +452,22 @@ class PlaybackController {
}
}
enum RenderMode {
unknown,
/// OpenGL on a real GPU: shaders work.
gpu,
/// OpenGL on a software rasterizer (llvmpipe): shaders crawl.
softwareGl,
/// media_kit's software fallback: shaders are ignored.
software,
}
class ShaderNotice {
final int id;
final String text;
const ShaderNotice(this.id, this.text);
}
/// mpv echoes numbers in its own format (`150` reads back `150.000000`).
bool pinMatches(String wanted, String got) {
if (wanted == got) return true;
@@ -1,6 +1,9 @@
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 'profile.dart';
@@ -14,6 +17,8 @@ const sessionShortcuts = [
('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'),
('Ctrl+R', 'Reload the video'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'),
];
@@ -31,13 +36,7 @@ class SettingsPage extends StatelessWidget {
const _Section(title: 'Player', child: _HwdecSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
const _Section(
title: 'Video quality & shaders',
enabled: false,
trailing: _SoonBadge(),
child: Text('Upscaling and shader presets for mpv.',
style: TextStyle(color: Colors.grey)),
),
const _Section(title: 'Video quality & shaders', child: _ShaderSettings()),
],
);
}
@@ -46,42 +45,22 @@ class SettingsPage extends StatelessWidget {
class _Section extends StatelessWidget {
final String title;
final Widget child;
final bool enabled;
final Widget? trailing;
const _Section(
{required this.title, required this.child, this.enabled = true, this.trailing});
const _Section({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Panel(
enabled: enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [SectionLabel(title, trailing: trailing), child],
children: [SectionLabel(title), child],
),
),
);
}
}
class _SoonBadge extends StatelessWidget {
const _SoonBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: const Text('Coming soon', style: TextStyle(fontSize: 12)),
);
}
}
class _HwdecSetting extends StatefulWidget {
const _HwdecSetting();
@@ -140,6 +119,125 @@ class _HwdecSettingState extends State<_HwdecSetting> {
}
}
class _ShaderSettings extends StatelessWidget {
const _ShaderSettings();
Future<void> _addFiles() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick mpv GLSL shaders',
type: FileType.custom,
allowedExtensions: const ['glsl', 'hook'],
);
} catch (_) {
return;
}
final s = AppSettings.instance;
final current = s.customShaders.peek();
final added = [
for (final f in files)
if (f.path case final p? when !current.contains(p)) p,
];
if (added.isNotEmpty) s.setCustomShaders([...current, ...added]);
}
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
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)),
],
),
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),
label: const Text('Add .glsl files'),
),
],
),
if (custom.isEmpty)
const Text('None picked.', style: hint)
else
for (final (i, path) in custom.indexed)
Row(
children: [
Expanded(
child: Tooltip(
message: path,
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)),
),
],
),
],
);
});
}
}
class _UpdateSettings extends StatelessWidget {
final UpdateController? controller;
const _UpdateSettings({this.controller});
@@ -5,6 +5,10 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart';
import '../state/settings.dart';
import '../state/session_state.dart';
import '../update/updater.dart';
import '../widgets/dialogs.dart';
@@ -38,7 +42,12 @@ class _SessionScreenState extends State<SessionScreen> {
late final void Function() _stopKickWatch;
late final void Function() _stopSheetWatch;
late final void Function() _stopPlayerWatch;
late final void Function() _stopShaderWatch;
late final FocusNode _noFocus;
bool _shaderMenuOpen = false;
// Own toast ids, negative: the coordinator counts up from 1.
int _toastSeq = 0;
int _shaderNoticeSeen = 0;
@override
void initState() {
@@ -57,6 +66,13 @@ class _SessionScreenState extends State<SessionScreen> {
FocusManager.instance.primaryFocus?.unfocus();
}
});
// Shader messages (preset changes, load failures) become toasts.
_stopShaderWatch = effect(() {
final n = session.coordinator.playback.shaderNotice.value;
if (n == null || n.id == _shaderNoticeSeen) return;
_shaderNoticeSeen = n.id;
_toast(n.text);
});
// The fullscreen route holds its own `Video` of the old player: leave
// it before the player goes away, re-enter once the new one is up.
_stopPlayerWatch = effect(() {
@@ -88,6 +104,25 @@ class _SessionScreenState extends State<SessionScreen> {
coord.reloadVideo();
}
void _toast(String text) {
final coord = SessionState.instance.coordinator;
coord.toasts.value = [
...coord.toasts.peek(),
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
];
}
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
/// and applied live. A null quality keeps the saved one.
void _pickShader(ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
final coord = SessionState.instance.coordinator;
coord.markInput();
coord.playback.applyShaders(announce: true);
}
void _toggleFullscreen() {
if (_isFullscreen.peek()) {
if (mounted) {
@@ -106,6 +141,7 @@ class _SessionScreenState extends State<SessionScreen> {
_stopKickWatch();
_stopSheetWatch();
_stopPlayerWatch();
_stopShaderWatch();
_chat.dispose();
_chatFocus.dispose();
_swapLink.dispose();
@@ -122,6 +158,14 @@ class _SessionScreenState extends State<SessionScreen> {
return false;
}
final coord = SessionState.instance.coordinator;
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
final digit = _shaderKeys.indexOf(event.logicalKey);
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now;
_pickShader(
digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.keyR &&
HardwareKeyboard.instance.isControlPressed) {
_lastKeyAt = now;
@@ -150,6 +194,10 @@ class _SessionScreenState extends State<SessionScreen> {
return true;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
if (_shaderMenuOpen) {
if (mounted) Navigator.of(context, rootNavigator: true).maybePop();
return true;
}
if (_sheetOpen.peek()) {
_sheetOpen.value = false;
return true;
@@ -212,6 +260,12 @@ class _SessionScreenState extends State<SessionScreen> {
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
const MaterialDesktopPositionIndicator(),
const Spacer(),
ShaderControlButton(
focusNode: _noFocus,
renderMode: coord.playback.renderMode,
onPick: _pickShader,
onMenuOpen: (open) => _shaderMenuOpen = open,
),
const MaterialDesktopFullscreenButton(),
],
);
@@ -527,6 +581,16 @@ class _SessionScreenState extends State<SessionScreen> {
}
}
const _shaderKeys = [
LogicalKeyboardKey.digit0,
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
];
class _ReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _ReadyControlButton({required this.focusNode});
@@ -0,0 +1,74 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'shader_presets.dart';
const anime4kAssetDir = 'assets/shaders/anime4k';
/// mpv needs real files: copies the shipped Anime4K shaders out of the
/// asset bundle into `<supportDir>/shaders/anime4k-<version>`, once per
/// version (a `.complete` marker is written last, so a cut-short copy is
/// redone). Returns that directory.
Future<Directory> extractAnime4k(
Directory supportDir, {
AssetBundle? bundle,
}) async {
final dir = Directory(
'${supportDir.path}${Platform.pathSeparator}shaders'
'${Platform.pathSeparator}anime4k-$anime4kVersion');
final marker = File('${dir.path}${Platform.pathSeparator}.complete');
final files = anime4kAllFiles;
if (await marker.exists() &&
files.every((f) => File(_join(dir, f)).existsSync())) {
return dir;
}
await dir.create(recursive: true);
final b = bundle ?? rootBundle;
for (final name in [...files, 'LICENSE']) {
final data = await b.load('$anime4kAssetDir/$name');
await File(_join(dir, name)).writeAsBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
flush: true);
}
await marker.writeAsString(anime4kVersion, flush: true);
return dir;
}
/// Absolute paths of [preset]'s Anime4K files inside [dir].
List<String> anime4kPaths(
Directory dir, ShaderPreset preset, ShaderQuality quality) =>
[for (final f in anime4kFiles(preset, quality)) _join(dir, f)];
String _join(Directory dir, String name) =>
'${dir.path}${Platform.pathSeparator}$name';
/// Where the shipped shaders get extracted; swapped in tests.
Future<Directory> Function() shaderSupportDir = getApplicationSupportDirectory;
/// Absolute `glsl-shaders` entries for a choice, extracting the shipped
/// files first when needed. Empty for [ShaderPreset.off].
Future<List<String>> shaderPathsFor(
ShaderPreset preset,
ShaderQuality quality,
List<String> custom,
) async {
if (preset == ShaderPreset.off) return const [];
if (preset == ShaderPreset.custom) return [...custom];
final dir = await extractAnime4k(await shaderSupportDir());
return anime4kPaths(dir, preset, quality);
}
/// Why [paths] can't go to mpv (a missing file, or a path holding the
/// list separator), or null when they can.
String? shaderPathsProblem(List<String> paths, {bool? windows}) {
final sep = shaderListSeparator(windows: windows);
for (final p in paths) {
final name = p.split(RegExp(r'[/\\]')).last;
// Windows drive letters ("C:\") are fine: there the separator is ';'.
if (p.contains(sep)) return '"$name" has "$sep" in its path';
if (!File(p).existsSync()) return '"$name" not found';
}
return null;
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/playback_controller.dart';
import '../state/settings.dart';
import 'shader_presets.dart';
/// A shader pick from the menu; a null quality keeps the saved one.
typedef ShaderPick = (ShaderPreset, ShaderQuality?);
/// Compact shader button for the video controls bar (normal and
/// fullscreen). Opens a small menu: Off, the six Anime4K modes with
/// HQ / Fast, Custom. The menu is a route, so it outlives the controls
/// bar hiding under it; [onPick] runs on the caller's side.
class ShaderControlButton extends StatelessWidget {
final FocusNode focusNode;
final ReadonlySignal<RenderMode> renderMode;
final void Function(ShaderPreset preset, ShaderQuality? quality) onPick;
final ValueChanged<bool>? onMenuOpen;
const ShaderControlButton({
super.key,
required this.focusNode,
required this.renderMode,
required this.onPick,
this.onMenuOpen,
});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final short = switch (preset) {
ShaderPreset.off => 'Shaders',
ShaderPreset.custom => 'Custom',
_ => '${preset.label.replaceFirst('Mode ', '')} ${quality.label}',
};
return Tooltip(
message: 'Video shaders (Ctrl+1..6, Ctrl+0 off)',
child: TextButton.icon(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor:
preset == ShaderPreset.off ? Colors.white : Colors.amberAccent,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _open(context),
icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(short, style: const TextStyle(fontSize: 12)),
),
);
});
}
Future<void> _open(BuildContext context) async {
final s = AppSettings.instance;
final preset = s.shaderPreset.peek();
final quality = s.shaderQuality.peek();
final hasCustom = s.customShaders.peek().isNotEmpty;
final button = context.findRenderObject() as RenderBox;
final overlay =
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero),
ancestor: overlay),
),
Offset.zero & overlay.size,
);
final software = renderMode.peek() == RenderMode.software;
onMenuOpen?.call(true);
final pick = await showMenu<ShaderPick>(
context: context,
position: position,
popUpAnimationStyle: AnimationStyle.noAnimation,
// Wider than Material's 280 px cap: the mode rows hold two chips.
constraints: const BoxConstraints(minWidth: 340, maxWidth: 340),
items: [
if (software)
const PopupMenuItem(
enabled: false,
height: 32,
child: Text('Software rendering: shaders have no effect',
style: TextStyle(fontSize: 12, color: Colors.orange)),
),
CheckedPopupMenuItem(
value: (ShaderPreset.off, null),
checked: preset == ShaderPreset.off,
height: 36,
child: const _Row(label: 'Off', keys: 'Ctrl+0'),
),
for (final (i, mode) in ShaderPreset.modes.indexed)
_ModeEntry(
mode: mode,
keys: 'Ctrl+${i + 1}',
current: preset == mode ? quality : null,
),
CheckedPopupMenuItem(
value: (ShaderPreset.custom, null),
checked: preset == ShaderPreset.custom,
enabled: hasCustom,
height: 36,
child: Text(hasCustom
? 'Custom shaders'
: 'Custom shaders (pick files in Settings)'),
),
],
);
onMenuOpen?.call(false);
if (pick != null) onPick(pick.$1, pick.$2);
}
}
class _Row extends StatelessWidget {
final String label;
final String keys;
const _Row({required this.label, required this.keys});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Text(label)),
Text(keys, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
}
}
/// One Anime4K mode: the row picks it with the saved quality, the two
/// chips pick a quality explicitly.
class _ModeEntry extends PopupMenuEntry<ShaderPick> {
final ShaderPreset mode;
final String keys;
/// The active quality when this mode is the current one.
final ShaderQuality? current;
const _ModeEntry({
required this.mode,
required this.keys,
required this.current,
});
@override
double get height => 36;
@override
bool represents(ShaderPick? value) => value?.$1 == mode;
@override
State<_ModeEntry> createState() => _ModeEntryState();
}
class _ModeEntryState extends State<_ModeEntry> {
@override
Widget build(BuildContext context) {
final w = widget;
final theme = Theme.of(context);
void pick(ShaderQuality? q) => Navigator.of(context).pop((w.mode, q));
return InkWell(
onTap: () => pick(null),
child: Container(
height: w.height,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
SizedBox(
width: 36,
child: w.current != null
? Icon(Icons.done, size: 20, color: theme.colorScheme.primary)
: null,
),
SizedBox(width: 96, child: Text(w.mode.label)),
for (final q in ShaderQuality.values)
Padding(
padding: const EdgeInsets.only(right: 4),
child: ChoiceChip(
label: Text(q.label, style: const TextStyle(fontSize: 11)),
selected: w.current == q,
showCheckmark: false,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
labelPadding: const EdgeInsets.symmetric(horizontal: 4),
onSelected: (_) => pick(q),
),
),
const Spacer(),
Text(w.keys,
style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
),
),
);
}
}
@@ -0,0 +1,87 @@
import 'dart:io';
// Anime4K v4.0.1 presets, file for file as in Anime4K's mpv templates
// (md/Template/GLSL_Mac_Linux_{High,Low}-end/input.conf): HQ = the
// high-end GPU set, Fast = the low-end set.
/// Anime4K release the shipped files come from (assets/shaders/anime4k).
const anime4kVersion = '4.0.1';
enum ShaderPreset {
off('Off'),
a('Mode A'),
b('Mode B'),
c('Mode C'),
aa('Mode A+A'),
bb('Mode B+B'),
ca('Mode C+A'),
custom('Custom shaders');
final String label;
const ShaderPreset(this.label);
/// The six Anime4K modes, in Ctrl+1..Ctrl+6 order.
static const modes = [a, b, c, aa, bb, ca];
bool get isAnime4k => modes.contains(this);
}
enum ShaderQuality {
hq('HQ'),
fast('Fast');
final String label;
const ShaderQuality(this.label);
}
/// "Mode A (HQ)", "Off", "Custom shaders".
String shaderLabel(ShaderPreset preset, ShaderQuality quality) =>
preset.isAnime4k ? '${preset.label} (${quality.label})' : preset.label;
const _clamp = 'Anime4K_Clamp_Highlights.glsl';
const _down = [
'Anime4K_AutoDownscalePre_x2.glsl',
'Anime4K_AutoDownscalePre_x4.glsl',
];
/// Anime4K file names of [preset] in mpv order; empty for off/custom.
List<String> anime4kFiles(ShaderPreset preset, ShaderQuality quality) {
final hq = quality == ShaderQuality.hq;
// Big/small network sizes: HQ uses VL then M, Fast uses M then S.
final big = hq ? 'VL' : 'M';
final small = hq ? 'M' : 'S';
String restore(String size) => 'Anime4K_Restore_CNN_$size.glsl';
String soft(String size) => 'Anime4K_Restore_CNN_Soft_$size.glsl';
String up(String size) => 'Anime4K_Upscale_CNN_x2_$size.glsl';
String denoise(String size) => 'Anime4K_Upscale_Denoise_CNN_x2_$size.glsl';
return switch (preset) {
ShaderPreset.off || ShaderPreset.custom => const [],
ShaderPreset.a => [_clamp, restore(big), up(big), ..._down, up(small)],
ShaderPreset.b => [_clamp, soft(big), up(big), ..._down, up(small)],
ShaderPreset.c => [_clamp, denoise(big), ..._down, up(small)],
ShaderPreset.aa => [
_clamp, restore(big), up(big), restore(small), ..._down, up(small)
],
ShaderPreset.bb => [
_clamp, soft(big), up(big), ..._down, soft(small), up(small)
],
ShaderPreset.ca => [
_clamp, denoise(big), ..._down, restore(small), up(small)
],
};
}
/// Every file some preset needs: exactly what assets/shaders/anime4k holds.
List<String> get anime4kAllFiles => {
for (final p in ShaderPreset.modes)
for (final q in ShaderQuality.values) ...anime4kFiles(p, q),
}.toList()
..sort();
/// mpv's path-list separator: ';' on Windows, ':' elsewhere.
String shaderListSeparator({bool? windows}) =>
(windows ?? Platform.isWindows) ? ';' : ':';
/// The `glsl-shaders` value for [paths].
String joinShaderPaths(List<String> paths, {bool? windows}) =>
paths.join(shaderListSeparator(windows: windows));
+39
View File
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart';
import '../shaders/shader_presets.dart';
// App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded]
// finishes; setters write through.
@@ -11,6 +13,9 @@ const settingsBoxName = 'settings';
const _keyHwdecMode = 'hwdecMode';
const _keyHwdecCustom = 'hwdecCustom';
const _keyAutoCheckUpdates = 'autoCheckUpdates';
const _keyShaderPreset = 'shaderPreset';
const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders';
enum HwdecMode { auto, off, custom }
@@ -21,6 +26,13 @@ class AppSettings {
final hwdecCustom = signal('');
final autoCheckUpdates = signal(true);
/// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button).
final shaderPreset = signal(ShaderPreset.off);
final shaderQuality = signal(ShaderQuality.hq);
/// .glsl files of [ShaderPreset.custom], in mpv order.
final customShaders = signal<List<String>>(const []);
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when
/// custom is blank).
late final hwdec = computed(() => switch (hwdecMode.value) {
@@ -47,6 +59,18 @@ class AppSettings {
if (custom is String) hwdecCustom.value = custom;
final auto = box.get(_keyAutoCheckUpdates);
if (auto is bool) autoCheckUpdates.value = auto;
final preset = box.get(_keyShaderPreset);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
ShaderPreset.off;
final quality = box.get(_keyShaderQuality);
shaderQuality.value =
ShaderQuality.values.where((q) => q.name == quality).firstOrNull ??
ShaderQuality.hq;
final files = box.get(_keyCustomShaders);
if (files is List) {
customShaders.value = List.unmodifiable(files.whereType<String>());
}
} catch (e, st) {
debugPrint('load settings failed: $e\n$st');
}
@@ -67,6 +91,21 @@ class AppSettings {
_put(_keyAutoCheckUpdates, on);
}
void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name);
}
void setShaderQuality(ShaderQuality quality) {
shaderQuality.value = quality;
_put(_keyShaderQuality, quality.name);
}
void setCustomShaders(List<String> paths) {
customShaders.value = List.unmodifiable(paths);
_put(_keyCustomShaders, [...paths]);
}
Future<void> _put(String key, Object value) async {
try {
final box = _box ??