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:
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));
|
||||
@@ -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 ??
|
||||
|
||||
Reference in new issue
Block a user