Stabilisation quick fixes: orderly close, visible version and update band, chat pop-ups on, Windows shaders and hwdec, render logs
- Close: hide the window, dispose every player, thumbnail job and the sound engine, leave, then close normally; Linux runner _exit (the NVIDIA EGL exit handler crashed 10/10 closes, 0/41 now); Windows tears down inside the message loop, then TerminateProcess; watchdogs. - Version under the Settings tab; an update band instead of a dot, never over a video; checks at start and every 4 hours. - Chat pop-ups default to All again. - Shader read-back compared as a list (old mpv joins with ','), so Windows stops turning shaders off; Windows hwdec auto-copy. - Render diagnostics (`mpv: render` lines); RAM-only demuxer cache pin silences the file-cache warning; a volume readout test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e537f9d5f8
commit
ea607c1a52
29 files changed
+873
-168
No files matched your search
@@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
import 'app.dart';
|
||||
import 'src/playback/yt_dlp_app.dart';
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
import 'src/platform/window_memory.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
@@ -48,7 +49,8 @@ Future<void> main(List<String> args) async {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||
windowManager.addListener(_CloseGuard(memory));
|
||||
shutdownWindowMemory = memory;
|
||||
windowManager.addListener(_CloseGuard());
|
||||
final windowOptions = WindowOptions(
|
||||
size: memory.size,
|
||||
minimumSize: const Size(160, 90),
|
||||
@@ -77,29 +79,10 @@ Future<void> main(List<String> args) async {
|
||||
Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
/// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
/// see "left", a hosted server shuts down and no player outlives the
|
||||
/// engine.
|
||||
class _CloseGuard with WindowListener {
|
||||
final WindowMemory memory;
|
||||
bool _closing = false;
|
||||
_CloseGuard(this.memory);
|
||||
|
||||
@override
|
||||
Future<void> onWindowClose() async {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
await memory.save();
|
||||
try {
|
||||
await SessionState.instance.sfx.dispose().timeout(const Duration(milliseconds: 500));
|
||||
} catch (e) {
|
||||
debugPrint('sound shutdown on close failed: $e');
|
||||
}
|
||||
try {
|
||||
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('leave on close failed: $e');
|
||||
}
|
||||
await windowManager.destroy();
|
||||
}
|
||||
void onWindowClose() => unawaited(shutDownApp());
|
||||
}
|
||||
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
|
||||
int _running = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
final _inFlight = <String, Future<Thumbnail?>>{};
|
||||
final _players = <Player>{};
|
||||
bool _closed = false;
|
||||
|
||||
/// App close: no new thumbnails, running ones stop at their next check
|
||||
/// and dispose their player. Players still up after [deadline] are
|
||||
/// disposed here, so no mpv outlives the engine.
|
||||
Future<void> cancelAllThumbnails({
|
||||
Duration deadline = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
_closed = true;
|
||||
try {
|
||||
await Future.wait(_inFlight.values.toList()).timeout(deadline);
|
||||
} catch (_) {}
|
||||
for (final p in _players.toList()) {
|
||||
_players.remove(p);
|
||||
try {
|
||||
await p.dispose().timeout(deadline);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached or freshly made thumbnail of the video at [path], or null
|
||||
/// when it can't be made (not a video, unreadable, timed out, cancelled).
|
||||
/// Never throws. [cancelled] is checked while queued and before the
|
||||
/// expensive steps: a scrolled-away tile returns null without decoding.
|
||||
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
|
||||
if (_closed) return Future.value(null);
|
||||
final existing = _inFlight[path];
|
||||
if (existing != null) return existing;
|
||||
final f = _thumbnailFor(path, cancelled ?? () => false).catchError((
|
||||
final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
|
||||
Object e,
|
||||
StackTrace st,
|
||||
) {
|
||||
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
|
||||
bufferSize: 4 << 20,
|
||||
),
|
||||
);
|
||||
_players.add(player);
|
||||
try {
|
||||
final mpv = player.platform as NativePlayer;
|
||||
for (final (k, v) in [
|
||||
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await player.dispose();
|
||||
// [cancelAllThumbnails] may have taken it already.
|
||||
if (_players.remove(player)) await player.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../files/thumbnails.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'window_memory.dart';
|
||||
|
||||
// The one way out of the app (close button, Alt+F4, Update and restart).
|
||||
// Order matters: every mpv player goes before the engine does (a live one
|
||||
// calls a dead Dart callback), then the network goodbye, then the window
|
||||
// closes through the platform's normal path so the engine is torn down
|
||||
// inside the message loop. The runners end the process right after
|
||||
// (`linux/runner/main.cc`, `windows/runner/main.cpp`).
|
||||
|
||||
/// Saved on the way out; set by main once known.
|
||||
WindowMemory? shutdownWindowMemory;
|
||||
|
||||
bool _shuttingDown = false;
|
||||
|
||||
/// True once [shutDownApp] started.
|
||||
bool get shuttingDown => _shuttingDown;
|
||||
|
||||
/// Hides the window, releases every player and the sound device, leaves
|
||||
/// (others see "left", a hosted server stops), runs [beforeQuit], then
|
||||
/// closes the window. Every step is bounded; a second call does nothing.
|
||||
Future<void> shutDownApp({Future<void> Function()? beforeQuit}) async {
|
||||
if (_shuttingDown) return;
|
||||
_shuttingDown = true;
|
||||
final session = SessionState.instance;
|
||||
final total = Stopwatch()..start();
|
||||
await _step('window save', shutdownWindowMemory?.save(), const Duration(milliseconds: 500));
|
||||
await _step('hide', windowManager.hide(), const Duration(milliseconds: 500));
|
||||
await Future.wait([
|
||||
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
|
||||
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
|
||||
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
|
||||
]);
|
||||
// Windows: media_kit_video frees a player's video output on a thread of
|
||||
// its own after dispose returns; it must be done before the engine
|
||||
// stops, or the plugin's teardown waits on it for good.
|
||||
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
|
||||
// Jellyfin stop report, last diagnostics, the goodbye, then the hosted
|
||||
// server: 3 s in all.
|
||||
await _step('leave', session.leave(), const Duration(milliseconds: 2500));
|
||||
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
|
||||
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
|
||||
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
|
||||
try {
|
||||
await windowManager.setPreventClose(false);
|
||||
await windowManager.close();
|
||||
} catch (e) {
|
||||
debugPrint('window close failed: $e');
|
||||
await windowManager.destroy();
|
||||
}
|
||||
// The engine stops with the window, so this only fires when the close
|
||||
// did not go through. A kill, not exit(): no exit handlers (NVIDIA's
|
||||
// EGL one segfaults, Windows DLL detach can hang).
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
debugPrint('close: the window did not close; ending the process');
|
||||
Process.killPid(pid, ProcessSignal.sigkill);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _step(String what, Future<void>? f, Duration limit) async {
|
||||
if (f == null) return;
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
await f.timeout(limit);
|
||||
} on TimeoutException {
|
||||
debugPrint('close: $what did not finish in ${limit.inMilliseconds} ms');
|
||||
} catch (e) {
|
||||
debugPrint('close: $what failed: $e');
|
||||
}
|
||||
if (sw.elapsedMilliseconds > 200) debugPrint('close: $what took ${sw.elapsedMilliseconds} ms');
|
||||
}
|
||||
@@ -30,7 +30,9 @@ class PlaybackController {
|
||||
static const readAheadSecs = 150;
|
||||
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a
|
||||
/// 1.4 GB / 24 min file is ~8); higher bitrates get less time. In RAM:
|
||||
/// media_kit sets cache-on-disk but no cache-dir, so mpv falls back.
|
||||
/// `cache-on-disk=no` (media_kit sets it on but no cache-dir, so mpv
|
||||
/// fell back to RAM anyway, warning "Failed to create file cache." per
|
||||
/// open).
|
||||
static const maxBytes = 256 * 1024 * 1024;
|
||||
/// Back buffer (media_kit sets it to bufferSize too): enough for a short
|
||||
/// seek back without holding another 256 MiB.
|
||||
@@ -175,6 +177,7 @@ class PlaybackController {
|
||||
final existing = _video;
|
||||
if (existing != null) return existing;
|
||||
final hwdec = _hwdecOverride;
|
||||
_logRenderEnv();
|
||||
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
|
||||
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||
@@ -253,6 +256,7 @@ class PlaybackController {
|
||||
await _pin('cache-secs', '$readAheadSecs', on);
|
||||
await _pin('demuxer-max-bytes', '$ahead', on);
|
||||
await _pin('demuxer-max-back-bytes', '$back', on);
|
||||
await _pin('cache-on-disk', 'no', on);
|
||||
final hwdec = _hwdecOverride;
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec, on);
|
||||
@@ -310,13 +314,12 @@ class PlaybackController {
|
||||
}
|
||||
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 (joinShaderPaths(paths) != _shaders || problem != null) {
|
||||
final took = await setShaderPaths(paths);
|
||||
if (!identical(p, _player)) return;
|
||||
if (problem == null && got != value) {
|
||||
if (problem == null && !took) {
|
||||
problem = 'mpv did not take them';
|
||||
await setShaderValue('');
|
||||
await setShaderPaths(const []);
|
||||
}
|
||||
}
|
||||
if (problem != null) {
|
||||
@@ -326,20 +329,23 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets mpv `glsl-shaders` on the current player and returns the
|
||||
/// read-back (null on error).
|
||||
/// Sets mpv `glsl-shaders` to [paths] on the current player; true when
|
||||
/// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
|
||||
/// joined with ',').
|
||||
@visibleForTesting
|
||||
Future<String?> setShaderValue(String value) async {
|
||||
Future<bool> setShaderPaths(List<String> paths) async {
|
||||
final value = joinShaderPaths(paths);
|
||||
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;
|
||||
final took = shaderListMatches(paths, got);
|
||||
_shaders = took ? value : got;
|
||||
if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
|
||||
return took;
|
||||
} catch (e, st) {
|
||||
debugPrint('glsl-shaders=$value failed: $e\n$st');
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +396,9 @@ class PlaybackController {
|
||||
return;
|
||||
}
|
||||
if (log.prefix != 'libmpv_render') return;
|
||||
// Which GL the video output got (verbose, heard only during the
|
||||
// render-mode probe): kept in the app log.
|
||||
if (_glInfo.hasMatch(log.text)) _mpvLog.info('render: [libmpv_render] ${log.text.trim()}');
|
||||
if (log.text.contains('suspected software renderer')) {
|
||||
_setRenderMode(RenderMode.softwareGl);
|
||||
} else if (log.text.startsWith('GL_VERSION') &&
|
||||
@@ -402,7 +411,7 @@ class PlaybackController {
|
||||
_shaders = '';
|
||||
_shaderOp = _shaderOp.then((_) async {
|
||||
if (!identical(p, _player)) return;
|
||||
await setShaderValue('');
|
||||
await setShaderPaths(const []);
|
||||
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
|
||||
});
|
||||
}
|
||||
@@ -414,6 +423,52 @@ class PlaybackController {
|
||||
static final _mpvLog = createLogger('mpv');
|
||||
static final _mpvThrottle = LogThrottle(burst: 5);
|
||||
|
||||
// mpv's GL init lines (`GL_VENDOR='...'`, `Detected desktop OpenGL 4.6.`).
|
||||
static final _glInfo =
|
||||
RegExp(r'^\s*(GL_(VENDOR|RENDERER|VERSION|SHADING_LANGUAGE_VERSION)=|Detected (GLES|desktop OpenGL))');
|
||||
|
||||
// Once per app start: what decides how media_kit and mpv render (the
|
||||
// display server, Flutter's and Mesa's overrides). Not secrets.
|
||||
static bool _renderEnvLogged = false;
|
||||
static void _logRenderEnv() {
|
||||
if (_renderEnvLogged || !Platform.isLinux) return;
|
||||
_renderEnvLogged = true;
|
||||
const names = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'FLUTTER_LINUX_RENDERER',
|
||||
'LIBGL_ALWAYS_SOFTWARE',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
];
|
||||
final env = Platform.environment;
|
||||
_mpvLog.info('render env: ${[for (final n in names) '$n=${env[n] ?? '-'}'].join(' ')}');
|
||||
}
|
||||
|
||||
// Once per player, after its first frame: the decoder mpv picked; the
|
||||
// first time per app start also mpv's and ffmpeg's versions.
|
||||
static bool _versionsLogged = false;
|
||||
Player? _frameLoggedFor;
|
||||
Future<void> _logFirstFrame(Player p, int? width) async {
|
||||
if (width == null || width <= 0 || identical(p, _frameLoggedFor)) return;
|
||||
_frameLoggedFor = p;
|
||||
try {
|
||||
final dynamic platform = p.platform;
|
||||
final current = await platform?.getProperty('hwdec-current');
|
||||
final wanted = await platform?.getProperty('hwdec');
|
||||
_mpvLog.info('render: first frame ${p.state.width}x${p.state.height}, '
|
||||
'hwdec-current=${'$current'.isEmpty ? 'no' : current} (hwdec=$wanted), mode ${renderMode.peek().name}');
|
||||
if (!_versionsLogged) {
|
||||
_versionsLogged = true;
|
||||
_mpvLog.info('render: ${await platform?.getProperty('mpv-version')}, '
|
||||
'ffmpeg ${await platform?.getProperty('ffmpeg-version')}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('first frame info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void _toAppLog(PlayerLog log, {String player = ''}) {
|
||||
final level = log.level;
|
||||
if (level != 'warn' && level != 'error' && level != 'fatal') return;
|
||||
@@ -451,11 +506,15 @@ class PlaybackController {
|
||||
} finally {
|
||||
if (identical(p, _player)) _requestLog(p, logLevel);
|
||||
}
|
||||
if (!identical(p, _player) || renderMode.peek() != RenderMode.unknown) {
|
||||
if (!identical(p, _player)) return;
|
||||
final mode = renderMode.peek();
|
||||
if (mode != RenderMode.unknown) {
|
||||
_mpvLog.info('render: ${mode.name} (${mode == RenderMode.gpu ? 'OpenGL on the GPU' : 'OpenGL on the CPU'})');
|
||||
return;
|
||||
}
|
||||
_setRenderMode(RenderMode.software);
|
||||
debugPrint('video output: software rendering (no GL init logged)');
|
||||
_mpvLog.info("render: software (no GL init logged: media_kit's own fallback; "
|
||||
'its reason is on stderr, "media_kit: VideoOutput: ..." when run from a terminal)');
|
||||
if (_shaders.isNotEmpty) {
|
||||
_notice('Shaders have no effect: video uses software rendering');
|
||||
}
|
||||
@@ -525,6 +584,8 @@ class PlaybackController {
|
||||
_timeSubs.add(p.stream.completed.listen((done) {
|
||||
if (done && duration.value > Duration.zero) position.value = duration.value;
|
||||
}));
|
||||
_timeSubs.add(p.stream.width.listen((w) => _logFirstFrame(p, w)));
|
||||
unawaited(_logFirstFrame(p, p.state.width)); // a promoted preload has one already
|
||||
_timeSubs.add(p.stream.buffer.listen((d) {
|
||||
final ms = d.inMilliseconds;
|
||||
buffered.value = Duration(milliseconds: ms - ms % 250);
|
||||
@@ -691,6 +752,24 @@ class PlaybackController {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// App close: every mpv this controller holds goes before the engine
|
||||
/// does (a live one calls back into a dead isolate): the preload, the
|
||||
/// session player and the one still retiring, each bounded by [each].
|
||||
/// A player an open in flight brought up meanwhile goes too.
|
||||
Future<void> disposeAll({Duration each = const Duration(milliseconds: 1500)}) async {
|
||||
Future<void> bounded(Future<void> f) => f.timeout(each).catchError((Object _) {});
|
||||
for (var round = 0; round < 2; round++) {
|
||||
final pre = _pre;
|
||||
_pre = null;
|
||||
await Future.wait([
|
||||
if (pre != null) bounded(_disposePreload(pre)),
|
||||
bounded(_retiring),
|
||||
bounded(dispose()),
|
||||
]);
|
||||
if (_player == null && _pre == null) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Two frames: one may already be mid-build when [alive] flips. Then
|
||||
// the length of a route pop (the fullscreen route holds its own `Video`
|
||||
// and leaves animated). Bounded, since a hidden window renders no
|
||||
|
||||
@@ -198,8 +198,8 @@ class UpdateSettings extends StatelessWidget {
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('auto-check-updates'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Check for updates on start'),
|
||||
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
|
||||
title: const Text('Check for updates automatically'),
|
||||
subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
|
||||
value: s.autoCheckUpdates.value,
|
||||
onChanged: s.setAutoCheckUpdates,
|
||||
),
|
||||
|
||||
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
|
||||
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version;
|
||||
}
|
||||
|
||||
/// The version in a few characters, always on screen (under the strip's
|
||||
/// Settings tab): "1.2.1", "1.2.1-dev.2", "dev run".
|
||||
String shortVersion({String version = appVersion}) => version == '0.0.0-dev' ? 'dev run' : version;
|
||||
|
||||
const kRadius = 12.0;
|
||||
const kGap = 16.0;
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show isDevBuild;
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Compact "Update vX" pill for the party strip; hidden until there is
|
||||
/// something to install. Click opens the full [UpdateBanner].
|
||||
class UpdateIndicator extends StatelessWidget {
|
||||
/// The band across the top of the window while an update is up for
|
||||
/// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
|
||||
/// Later (gone until the next check or start). The shell keeps it off
|
||||
/// the full player while a video is on ([AppShell]).
|
||||
class UpdateBar extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const UpdateIndicator({super.key, this.controller});
|
||||
const UpdateBar({super.key, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final alert = m.isRequired || s.phase == UpdatePhase.failed;
|
||||
final fg = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
|
||||
final current = c.currentVersion;
|
||||
// A release (not a test build) that is behind: say which it runs.
|
||||
final behind = isDevBuild(current) ? null : 'You are running $current';
|
||||
final String title;
|
||||
String detail;
|
||||
switch (s.phase) {
|
||||
case UpdatePhase.downloading:
|
||||
title = 'Downloading $appName ${m.version}… ${((s.progress ?? 0) * 100).round()}%';
|
||||
detail = '';
|
||||
case UpdatePhase.applying:
|
||||
title = 'Installing $appName ${m.version}…';
|
||||
detail = '';
|
||||
case UpdatePhase.restarting:
|
||||
title = 'Restarting into $appName ${m.version}…';
|
||||
detail = '';
|
||||
case UpdatePhase.failed:
|
||||
title = 'Update to $appName ${m.version} failed';
|
||||
detail = '${s.error}';
|
||||
case UpdatePhase.available || UpdatePhase.idle:
|
||||
if (!s.canInstall) {
|
||||
title = 'This copy of $appName is older than the latest release (${m.version})';
|
||||
detail = 'Download the new release and replace this folder by hand';
|
||||
} else if (m.isRequired) {
|
||||
title = '$appName ${m.version} is required to join servers on it';
|
||||
detail = m.notes;
|
||||
} else {
|
||||
title = '$appName ${m.version} is ready';
|
||||
detail = m.notes;
|
||||
}
|
||||
detail = [?behind, detail].where((t) => t.isNotEmpty).join(' · ');
|
||||
}
|
||||
final busy = s.phase == UpdatePhase.downloading ||
|
||||
s.phase == UpdatePhase.applying ||
|
||||
s.phase == UpdatePhase.restarting;
|
||||
final label = switch (s.phase) {
|
||||
UpdatePhase.downloading =>
|
||||
'Updating ${((s.progress ?? 0) * 100).round()}%',
|
||||
UpdatePhase.applying => 'Installing…',
|
||||
UpdatePhase.restarting => 'Restarting…',
|
||||
UpdatePhase.failed => 'Update failed',
|
||||
_ => m.isRequired ? 'Required update' : 'Update v${m.version}',
|
||||
};
|
||||
return Tooltip(
|
||||
message: 'Show update',
|
||||
child: FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed
|
||||
? scheme.errorContainer
|
||||
: null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
return Material(
|
||||
key: const ValueKey('update-bar'),
|
||||
color: alert ? scheme.errorContainer : scheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (busy)
|
||||
SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: fg,
|
||||
value: s.phase == UpdatePhase.downloading ? s.progress : null,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.system_update_alt, size: 20, color: fg),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600)),
|
||||
if (detail.isNotEmpty)
|
||||
Tooltip(
|
||||
message: detail,
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: Text(detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fg.withValues(alpha: 0.8), fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!busy) ...[
|
||||
const SizedBox(width: 12),
|
||||
if (!m.isRequired)
|
||||
TextButton(
|
||||
key: const ValueKey('update-later'),
|
||||
style: TextButton.styleFrom(foregroundColor: fg),
|
||||
onPressed: c.dismiss,
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (s.canInstall) ...[
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
key: const ValueKey('update-install'),
|
||||
onPressed: c.install,
|
||||
child: Text(s.phase == UpdatePhase.failed ? 'Retry' : 'Update and restart'),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
onPressed: () => showUpdateDialog(context, controller: c),
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(label),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -85,6 +156,9 @@ Future<void> showUpdateDialog(BuildContext context, {UpdateController? controlle
|
||||
);
|
||||
}
|
||||
|
||||
/// How often a running app looks again (auto-check on).
|
||||
const updateCheckEvery = Duration(hours: 4);
|
||||
|
||||
enum UpdateCheck { notRun, checking, done }
|
||||
|
||||
/// Progress of this run's update check, for Settings/About wording.
|
||||
|
||||
@@ -2242,6 +2242,17 @@ class SessionCoordinator {
|
||||
return 'connect failed: $e';
|
||||
}
|
||||
|
||||
/// App close, before [leave]: the players go first (no mpv may outlive
|
||||
/// the engine). The Jellyfin stop report [leave] sends keeps the
|
||||
/// position they had.
|
||||
Future<void> releasePlayers() async {
|
||||
if (_playerReady) _lastSnap = _snapNow();
|
||||
_playerReady = false;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
await playback.disposeAll();
|
||||
}
|
||||
|
||||
Future<void> leave({bool silent = false}) async {
|
||||
_invalidateAttempts();
|
||||
await _teardown(silent: silent);
|
||||
|
||||
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
|
||||
/// The `glsl-shaders` value for [paths].
|
||||
String joinShaderPaths(List<String> paths, {bool? windows}) =>
|
||||
paths.join(shaderListSeparator(windows: windows));
|
||||
|
||||
/// Whether mpv's read-back [got] of `glsl-shaders` is the list [paths].
|
||||
/// Recent mpv prints a path list joined with [shaderListSeparator]; older
|
||||
/// mpv (0.37 and before, the bundled Windows libmpv among them) joins it
|
||||
/// with ','. Matched against each joined form rather than split: a path
|
||||
/// may itself hold ':' (a Windows drive letter) or ','.
|
||||
bool shaderListMatches(List<String> paths, String? got) =>
|
||||
got != null && const [',', ';', ':'].any((sep) => paths.join(sep) == got);
|
||||
@@ -128,6 +128,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
SessionState.instance.route.value == AppRoute.session &&
|
||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||
|
||||
/// A video is on in the session (not finished): the update bar stays
|
||||
/// off the full player.
|
||||
final _watching = computed(() {
|
||||
final s = SessionState.instance;
|
||||
final r = s.coordinator.serverState.value;
|
||||
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
|
||||
});
|
||||
|
||||
PlayerMode get mode => _mode;
|
||||
PlayerMode get shownMode => _shown;
|
||||
bool get fullscreen => _fullscreen;
|
||||
@@ -226,10 +234,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
stop();
|
||||
}
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
_updateTimer?.cancel();
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_alone.dispose();
|
||||
_hasMedia.dispose();
|
||||
_watching.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
@@ -245,9 +255,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (mounted) f();
|
||||
});
|
||||
|
||||
Timer? _updateTimer;
|
||||
|
||||
/// At start, then every [updateCheckEvery] while the app runs (when the
|
||||
/// auto-check setting is on).
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (!mounted) return;
|
||||
_updateTimer = Timer.periodic(updateCheckEvery, (_) {
|
||||
if (s.autoCheckUpdates.peek()) unawaited(runUpdateCheck(widget.updates, true));
|
||||
});
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
@@ -616,6 +634,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
final online = _online.value;
|
||||
final alone = _alone.value;
|
||||
final hasMedia = _hasMedia.value;
|
||||
final onPlayer = _shown == PlayerMode.full && _watching.value;
|
||||
return Column(
|
||||
children: [
|
||||
if (!_fullscreen)
|
||||
@@ -632,9 +651,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onInvite: () => _connect(ConnectMode.host),
|
||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
|
||||
),
|
||||
Expanded(
|
||||
key: const ValueKey('body'),
|
||||
child: Row(
|
||||
|
||||
@@ -5,11 +5,9 @@ import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
@@ -56,7 +54,6 @@ class PartyStrip extends StatelessWidget {
|
||||
|
||||
/// Online with something up but the player closed: brings it back.
|
||||
final VoidCallback? onShowPlayer;
|
||||
final UpdateController? updates;
|
||||
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
@@ -73,7 +70,6 @@ class PartyStrip extends StatelessWidget {
|
||||
required this.onProfile,
|
||||
this.onInvite,
|
||||
this.onShowPlayer,
|
||||
this.updates,
|
||||
this.windowControls = true,
|
||||
});
|
||||
|
||||
@@ -125,8 +121,10 @@ class PartyStrip extends StatelessWidget {
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
// The version, a rest of the mouse away.
|
||||
// The version: always in sight under Settings,
|
||||
// the full one a rest of the mouse away.
|
||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||
caption: t == ShellTab.settings ? shortVersion() : null,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
@@ -135,7 +133,6 @@ class PartyStrip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online && onShowPlayer != null)
|
||||
IconButton(
|
||||
@@ -369,6 +366,8 @@ class StripTab extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
/// Shown after resting on the tab.
|
||||
final String? tooltip;
|
||||
/// Small text under the tab (the version under Settings).
|
||||
final String? caption;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
@@ -376,6 +375,7 @@ class StripTab extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
this.tooltip,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -391,28 +391,55 @@ class StripTab extends StatelessWidget {
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
height: 3,
|
||||
width: selected ? 22 : 0,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
height: 3,
|
||||
width: selected ? 22 : 0,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
if (caption case final c?)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 2,
|
||||
height: 13,
|
||||
child: OverflowBox(
|
||||
maxWidth: 160,
|
||||
child: Text(
|
||||
c,
|
||||
key: const ValueKey('strip-version'),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
height: 1.2,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
@@ -40,6 +42,12 @@ const _keyShareDiagnostics = 'shareDiagnostics';
|
||||
|
||||
enum HwdecMode { auto, off, custom }
|
||||
|
||||
/// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on
|
||||
/// the GPU, frames copied back): media_kit renders through ANGLE, where
|
||||
/// plain 'auto' picks the dxva2 interop, which cannot create its EGL
|
||||
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
|
||||
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
||||
|
||||
/// When chat messages pop up as toasts (they always reach the chat).
|
||||
enum ChatPopups {
|
||||
all('All'),
|
||||
@@ -88,8 +96,8 @@ class AppSettings {
|
||||
/// Send this app's log lines to the server's session log while in a
|
||||
/// session (never chat). Off: nothing is sent but a note that it is off.
|
||||
final shareDiagnostics = signal(true);
|
||||
/// Chat message toasts (default: only when paused). Applies live.
|
||||
final chatPopups = signal(ChatPopups.paused);
|
||||
/// Chat message toasts (default: all). Applies live.
|
||||
final chatPopups = signal(ChatPopups.all);
|
||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||
final soundVolume = signal(1.0);
|
||||
/// `SfxKind` names switched off one by one.
|
||||
@@ -109,13 +117,13 @@ class AppSettings {
|
||||
/// .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).
|
||||
/// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
|
||||
/// ([autoHwdec] when custom is blank).
|
||||
late final hwdec = computed(() => switch (hwdecMode.value) {
|
||||
HwdecMode.auto => 'auto',
|
||||
HwdecMode.auto => autoHwdec,
|
||||
HwdecMode.off => 'no',
|
||||
HwdecMode.custom =>
|
||||
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(),
|
||||
hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
|
||||
});
|
||||
|
||||
Future<void>? _load;
|
||||
@@ -160,7 +168,7 @@ class AppSettings {
|
||||
final share = box.get(_keyShareDiagnostics);
|
||||
if (share is bool) shareDiagnostics.value = share;
|
||||
final popups = box.get(_keyChatPopups);
|
||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
|
||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
||||
final vol = box.get(_keySoundVolume);
|
||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||
final off = box.get(_keySoundsOff);
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// In-app update flow behind the Home update indicator. Same code path as
|
||||
import '../platform/app_shutdown.dart';
|
||||
|
||||
// In-app update flow behind the update bar (UpdateBar). Same code path as
|
||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||
// UI and the restart. Check failures are logged only — the banner shows up just when
|
||||
// there is something to install.
|
||||
@@ -28,26 +30,23 @@ class UpdateStatus {
|
||||
// Process args as main() received them — restart relaunches with these.
|
||||
List<String> launchArgs = const [];
|
||||
|
||||
// Relaunch the (now replaced) binary detached with the same args, then
|
||||
// exit this process.
|
||||
Future<Never> restartApp(String executable, List<String> args) async {
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await Process.start(executable, keep,
|
||||
mode: ProcessStartMode.detached,
|
||||
workingDirectory: Directory.current.path);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||
|
||||
// Release Hive's box locks before the new instance opens the same boxes.
|
||||
// The orderly close (players, leave), Hive's box locks released before
|
||||
// the new instance opens the same boxes, the (now replaced) binary
|
||||
// relaunched detached with the same args, then the window closes.
|
||||
Future<void> _closeAndRestart(String executable, List<String> args) async {
|
||||
try {
|
||||
await Hive.close();
|
||||
} catch (e) {
|
||||
_log.warning('closing hive before restart: $e');
|
||||
}
|
||||
await restartApp(executable, args);
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await shutDownApp(beforeQuit: () async {
|
||||
try {
|
||||
await Hive.close();
|
||||
} catch (e) {
|
||||
_log.warning('closing hive before restart: $e');
|
||||
}
|
||||
await Process.start(executable, keep,
|
||||
mode: ProcessStartMode.detached,
|
||||
workingDirectory: Directory.current.path);
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateController {
|
||||
@@ -115,15 +114,22 @@ class UpdateController {
|
||||
return;
|
||||
}
|
||||
_log.info('update available: ${m.version} (proto ${m.protocol})');
|
||||
lastCheck.value = 'Update v${m.version} available';
|
||||
// A re-check never cuts into an install under way.
|
||||
if (_installing) return;
|
||||
status.value = UpdateStatus(UpdatePhase.available,
|
||||
manifest: m, canInstall: root != null);
|
||||
lastCheck.value = 'Update v${m.version} available';
|
||||
} catch (e) {
|
||||
_log.warning('update check failed: $e');
|
||||
lastCheck.value = 'Update check failed: $e';
|
||||
}
|
||||
}
|
||||
|
||||
bool get _installing => switch (status.value.phase) {
|
||||
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
void dismiss() {
|
||||
final s = status.value;
|
||||
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../update/update_controller.dart';
|
||||
|
||||
// "Update available" card (Home update indicator dialog). Kicks off the one
|
||||
// "Update available" card (Settings → Show update dialog). Kicks off the one
|
||||
// background check on first build; renders nothing until there is an
|
||||
// update to offer. Never shown over playback.
|
||||
class UpdateBanner extends StatefulWidget {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "my_application.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
g_autoptr(MyApplication) app = my_application_new();
|
||||
return g_application_run(G_APPLICATION(app), argc, argv);
|
||||
MyApplication* app = my_application_new();
|
||||
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
||||
// The window is gone and the engine with it (the app disposed its mpv
|
||||
// players and left before closing the window). Skip exit(): its handlers
|
||||
// include NVIDIA's EGL teardown (libEGL_nvidia), which segfaults inside
|
||||
// exit() for Flutter apps. Nothing is left to flush but stdio.
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(status);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
@@ -38,6 +41,28 @@ static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
|
||||
fl_method_call_respond(call, response, nullptr);
|
||||
}
|
||||
|
||||
// Ends the process 15 s after the first close request whatever is still
|
||||
// running: the app's close (lib/src/platform/app_shutdown.dart) is bounded
|
||||
// to a few seconds, but a native teardown can block for good, and the
|
||||
// process must never stay behind a hidden window.
|
||||
static gpointer exit_watchdog(gpointer data) {
|
||||
g_usleep(15 * G_USEC_PER_SEC);
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(0);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static gboolean window_delete_cb(GtkWidget* widget, GdkEvent* event,
|
||||
gpointer user_data) {
|
||||
static gboolean armed = FALSE;
|
||||
if (!armed) {
|
||||
armed = TRUE;
|
||||
g_thread_unref(g_thread_new("exit-watchdog", exit_watchdog, nullptr));
|
||||
}
|
||||
return FALSE; // window_manager decides whether the window closes
|
||||
}
|
||||
|
||||
static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
|
||||
gpointer user_data) {
|
||||
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
|
||||
@@ -111,6 +136,10 @@ static void my_application_activate(GApplication* application) {
|
||||
self);
|
||||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
// Before the plugins: window_manager's delete-event handler stops the
|
||||
// emission while the app holds the close.
|
||||
g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_cb),
|
||||
nullptr);
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:watch_together/src/schedule/ui/schedule_page.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/screens/home/update_indicator.dart';
|
||||
import 'package:watch_together/src/shell/app_shell.dart';
|
||||
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
@@ -58,13 +60,18 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('chat pop-ups default to All', () {
|
||||
expect(AppSettings().chatPopups.value, ChatPopups.all);
|
||||
});
|
||||
|
||||
test('hwdec value follows mode', () {
|
||||
final s = AppSettings();
|
||||
expect(s.hwdec.value, 'auto');
|
||||
expect(s.hwdec.value, autoHwdec);
|
||||
expect(autoHwdec, Platform.isWindows ? 'auto-copy' : 'auto');
|
||||
s.setHwdecMode(HwdecMode.off);
|
||||
expect(s.hwdec.value, 'no');
|
||||
s.setHwdecMode(HwdecMode.custom);
|
||||
expect(s.hwdec.value, 'auto');
|
||||
expect(s.hwdec.value, autoHwdec);
|
||||
s.setHwdecCustom(' vaapi ');
|
||||
expect(s.hwdec.value, 'vaapi');
|
||||
});
|
||||
@@ -150,7 +157,7 @@ void main() {
|
||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('settings-updates')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Check for updates on start'), findsOneWidget);
|
||||
expect(find.text('Check for updates automatically'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Toggle ready'), findsOneWidget);
|
||||
@@ -288,4 +295,53 @@ void main() {
|
||||
expect(tester.takeException(), isNull, reason: source);
|
||||
}
|
||||
});
|
||||
|
||||
group('update bar', () {
|
||||
const m = UpdateManifest(
|
||||
version: '1.2.1', protocol: 1, url: 'http://x/a.tgz', notes: 'Faster joins');
|
||||
Future<UpdateController> pump(WidgetTester tester, UpdateStatus s,
|
||||
{String current = '1.2.0'}) async {
|
||||
final c = UpdateController(
|
||||
currentVersion: current,
|
||||
executablePath: '/nowhere/watch_together',
|
||||
restart: (_, _) async {});
|
||||
c.status.value = s;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(body: Column(children: [UpdateBar(controller: c)]))));
|
||||
return c;
|
||||
}
|
||||
|
||||
testWidgets('says what is ready, the version running, Later hides it',
|
||||
(tester) async {
|
||||
await pump(tester,
|
||||
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true));
|
||||
expect(find.text('Starsync 1.2.1 is ready'), findsOneWidget);
|
||||
expect(find.text('You are running 1.2.0 · Faster joins'), findsOneWidget);
|
||||
expect(find.text('Update and restart'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('update-later')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a copy that cannot update itself says it is older',
|
||||
(tester) async {
|
||||
await pump(tester, const UpdateStatus(UpdatePhase.available, manifest: m));
|
||||
expect(find.text('This copy of Starsync is older than the latest release (1.2.1)'),
|
||||
findsOneWidget);
|
||||
expect(find.text('Update and restart'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a test build is not told which version it runs',
|
||||
(tester) async {
|
||||
await pump(tester,
|
||||
const UpdateStatus(UpdatePhase.available, manifest: m, canInstall: true),
|
||||
current: '1.2.1-dev.2');
|
||||
expect(find.text('Faster joins'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('nothing to install: no bar', (tester) async {
|
||||
await pump(tester, const UpdateStatus(UpdatePhase.idle));
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -27,9 +27,9 @@ void main() {
|
||||
s.setShaderPreset(ShaderPreset.a);
|
||||
await p.init();
|
||||
final dynamic mpv = p.player.platform;
|
||||
final modeA = joinShaderPaths(
|
||||
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []));
|
||||
expect(await mpv.getProperty('glsl-shaders'), modeA);
|
||||
final modeA =
|
||||
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
|
||||
expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||
expect(p.shaderNotice.value, isNull);
|
||||
|
||||
// Live change, announced.
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
final caFast = await shaderPathsFor(
|
||||
ShaderPreset.ca, ShaderQuality.fast, const []);
|
||||
expect(caFast.first, startsWith(support.path));
|
||||
expect(await mpv.getProperty('glsl-shaders'), joinShaderPaths(caFast));
|
||||
expect(shaderListMatches(caFast, await mpv.getProperty('glsl-shaders')), isTrue);
|
||||
expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)');
|
||||
|
||||
// A missing custom file: toast, off, nothing set.
|
||||
|
||||
@@ -70,6 +70,22 @@ void main() {
|
||||
expect(joinShaderPaths([], windows: false), '');
|
||||
});
|
||||
|
||||
test("mpv's read-back: the list in ',', ';' or ':' form", () {
|
||||
const win = [r'C:\Users\a b\shaders\x.glsl', r'D:\y.glsl'];
|
||||
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl;D:\y.glsl'), isTrue, reason: 'new mpv, Windows');
|
||||
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl,D:\y.glsl'), isTrue, reason: 'old mpv, Windows');
|
||||
const unix = ['/home/a/x.glsl', '/home/a/y.glsl'];
|
||||
expect(shaderListMatches(unix, '/home/a/x.glsl:/home/a/y.glsl'), isTrue, reason: 'new mpv, Linux');
|
||||
expect(shaderListMatches(unix, '/home/a/x.glsl,/home/a/y.glsl'), isTrue, reason: 'old mpv, Linux');
|
||||
expect(shaderListMatches([r'C:\x.glsl'], r'C:\x.glsl'), isTrue, reason: 'one path');
|
||||
expect(shaderListMatches(const [], ''), isTrue, reason: 'off');
|
||||
expect(shaderListMatches(win, r'C:\Users\a b\shaders\x.glsl'), isFalse, reason: 'one dropped');
|
||||
expect(shaderListMatches(unix, '/home/a/y.glsl:/home/a/x.glsl'), isFalse, reason: 'order matters');
|
||||
expect(shaderListMatches(unix, ''), isFalse, reason: 'mpv kept none');
|
||||
expect(shaderListMatches(const [], 'null'), isFalse);
|
||||
expect(shaderListMatches(unix, null), isFalse, reason: 'read failed');
|
||||
});
|
||||
|
||||
test('path problems: missing file, separator inside a path', () {
|
||||
final d = _fresh('shaders_problem');
|
||||
final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//');
|
||||
|
||||
@@ -68,14 +68,14 @@ void main() {
|
||||
coord.toasts.value = [];
|
||||
});
|
||||
|
||||
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
||||
Future<void> pumpShell(WidgetTester tester, Size size, {UpdateController? updates}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: AppShell(
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
|
||||
autoUpdateCheck: false,
|
||||
windowControls: false,
|
||||
videoBuilder: (context, controls) => const FakeVideo(),
|
||||
@@ -93,6 +93,36 @@ void main() {
|
||||
member('f', 'Finn'),
|
||||
];
|
||||
|
||||
group('version and update bar', () {
|
||||
testWidgets('the version under Settings; the bar on the tabs, off the full player until the video ends',
|
||||
(tester) async {
|
||||
final updates = UpdateController(currentVersion: '0.0.0-dev');
|
||||
await pumpShell(tester, const Size(1280, 800), updates: updates);
|
||||
expect(find.text('dev run'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||
updates.status.value = const UpdateStatus(UpdatePhase.available,
|
||||
manifest: UpdateManifest(version: '9.9.9', protocol: 1, url: 'http://x/a.tgz', notes: ''),
|
||||
canInstall: true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
|
||||
online(party);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsNothing);
|
||||
final r = coord.serverState.value!;
|
||||
coord.serverState.value = StateBroadcast(
|
||||
eventId: 2,
|
||||
transport: 'finished',
|
||||
position: 0,
|
||||
serverTime: r.serverTime,
|
||||
mediaId: r.mediaId,
|
||||
media: r.media,
|
||||
members: r.members,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('update-bar')), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('party strip', () {
|
||||
testWidgets('offline: Join and Host, no party', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 800));
|
||||
|
||||
@@ -205,6 +205,26 @@ void main() {
|
||||
expect(dev.status.value.phase, UpdatePhase.idle);
|
||||
});
|
||||
|
||||
test('Later hides it until the next check; a check leaves an install be',
|
||||
() async {
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${work.path}/stray-binary',
|
||||
restart: (_, _) async {});
|
||||
await c.checkOnce();
|
||||
expect(c.status.value.phase, UpdatePhase.available);
|
||||
c.dismiss();
|
||||
expect(c.status.value.phase, UpdatePhase.idle);
|
||||
await c.checkNow();
|
||||
expect(c.status.value.phase, UpdatePhase.available);
|
||||
final m = c.status.value.manifest;
|
||||
c.status.value = UpdateStatus(UpdatePhase.downloading,
|
||||
manifest: m, progress: 0.4, canInstall: true);
|
||||
await c.checkNow();
|
||||
expect(c.status.value.phase, UpdatePhase.downloading);
|
||||
});
|
||||
|
||||
test('failed install shows error, install untouched, retry works',
|
||||
() async {
|
||||
server.manifestExtra = {'sha256': '0' * 64};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
|
||||
import 'package:watch_together/src/controls/model/controls_prefs.dart';
|
||||
import 'package:watch_together/src/controls/frame.dart';
|
||||
import 'package:watch_together/src/shell/session_player.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
|
||||
// The wheel anywhere on the full player flashes the volume readout, in
|
||||
// both skins, windowed and fullscreen, with the controls up or hidden.
|
||||
// The picture is a black box with the session's controls on it (never a
|
||||
// media_kit `Video`).
|
||||
|
||||
void main() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final s = AppSettings.instance;
|
||||
|
||||
// A real player for the volume steps to go to. Never disposed: the
|
||||
// steps' mpv calls start in the test's fake time and never finish, so a
|
||||
// dispose would wait on them for ever; the test process ending ends it.
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
await coord.playback.init();
|
||||
});
|
||||
|
||||
testWidgets('wheel over the video: readout for about a second', (tester) async {
|
||||
addTearDown(() => s.controlsSkin.value = ControlsSkin.standard);
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
final readout = find.byKey(const ValueKey('volume-readout'));
|
||||
double opacity() => readout.evaluate().isEmpty
|
||||
? 0
|
||||
: tester.widget<AnimatedOpacity>(find.ancestor(of: readout, matching: find.byType(AnimatedOpacity)).first).opacity;
|
||||
Future<void> wheel() async {
|
||||
await tester.sendEventToBinding(
|
||||
PointerScrollEvent(kind: PointerDeviceKind.mouse, position: const Offset(640, 300), scrollDelta: const Offset(0, -60)));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
for (final skin in ControlsSkin.values) {
|
||||
for (final fullscreen in [false, true]) {
|
||||
final what = '${skin.name}, ${fullscreen ? 'fullscreen' : 'windowed'}';
|
||||
s.controlsSkin.value = skin;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: SessionPlayer(
|
||||
key: ValueKey(what),
|
||||
mode: PlayerMode.full,
|
||||
fullscreen: fullscreen,
|
||||
onExpand: () {},
|
||||
onCollapse: () {},
|
||||
onClose: () {},
|
||||
onToggleFullscreen: () {},
|
||||
onToggleChat: () {},
|
||||
onChangeVideo: () {},
|
||||
onOpenIntegrations: () {},
|
||||
onLeave: () {},
|
||||
windowControls: false,
|
||||
videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
if (controls != null) controls(VideoState()),
|
||||
]),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
expect(opacity(), 0, reason: '$what: nothing before a step');
|
||||
|
||||
// Controls up.
|
||||
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isTrue, reason: what);
|
||||
await wheel();
|
||||
expect(opacity(), 1, reason: '$what, controls up: shown');
|
||||
await tester.pump(const Duration(milliseconds: 900));
|
||||
expect(opacity(), 1, reason: '$what: still up within the second');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(opacity(), 0, reason: '$what: gone after the second');
|
||||
|
||||
// Controls hidden (no mouse movement past the hide delay).
|
||||
await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(tester.state<ControlsFrameState>(find.byType(ControlsFrame)).visible, isFalse, reason: what);
|
||||
await wheel();
|
||||
expect(opacity(), 1, reason: '$what, controls hidden: shown');
|
||||
await tester.pump(const Duration(milliseconds: 1200));
|
||||
expect(opacity(), 0, reason: '$what: gone after the second');
|
||||
}
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Ends the process after |ms| whatever is still running: the app's close
|
||||
// (lib/src/platform/app_shutdown.dart) is bounded, but a native teardown
|
||||
// can block for good (media_kit_video's ~VideoOutput waits on a texture
|
||||
// callback the stopped engine never sends). The process must never stay
|
||||
// behind a hidden window.
|
||||
void ExitAfter(DWORD ms) {
|
||||
std::thread([ms] {
|
||||
::Sleep(ms);
|
||||
fflush(nullptr);
|
||||
::TerminateProcess(::GetCurrentProcess(), 0);
|
||||
}).detach();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
@@ -60,6 +79,8 @@ bool FlutterWindow::OnCreate() {
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
// The engine and the plugins go now; the app released its players first.
|
||||
ExitAfter(3000);
|
||||
window_channel_ = nullptr;
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
@@ -72,6 +93,13 @@ LRESULT
|
||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// A close request starts the app's close (players, leave), at most a
|
||||
// few seconds; past 15 s the process ends anyway.
|
||||
if (message == WM_CLOSE && !close_requested_) {
|
||||
close_requested_ = true;
|
||||
ExitAfter(15000);
|
||||
}
|
||||
|
||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||
if (flutter_controller_) {
|
||||
std::optional<LRESULT> result =
|
||||
|
||||
@@ -34,6 +34,9 @@ class FlutterWindow : public Win32Window {
|
||||
// "starsync/window": `attention` flashes the taskbar entry while the
|
||||
// window is not in front (lib/src/platform/attention.dart).
|
||||
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_;
|
||||
|
||||
// The first WM_CLOSE arms the exit watchdog.
|
||||
bool close_requested_ = false;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
@@ -2,6 +2,8 @@
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
@@ -38,6 +40,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
::DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
// The window closed through WM_CLOSE (the app disposed its mpv players
|
||||
// and left first), so the engine and plugins were torn down inside the
|
||||
// loop. End here without the CRT exit and DLL_PROCESS_DETACH: a driver
|
||||
// or libmpv detach handler can hang waiting on threads that are gone.
|
||||
// Nothing is left to flush but stdio.
|
||||
fflush(nullptr);
|
||||
::CoUninitialize();
|
||||
::TerminateProcess(::GetCurrentProcess(), EXIT_SUCCESS);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Reference in new issue
Block a user