From ea607c1a52bea983bbc8dac8d21348a8f3cf12cf Mon Sep 17 00:00:00 2001 From: foxyz Date: Thu, 15 Oct 2026 18:01:44 +0300 Subject: [PATCH] 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 --- README.md | 17 ++- apps/desktop/lib/main.dart | 31 +---- apps/desktop/lib/src/files/thumbnails.dart | 27 +++- .../lib/src/platform/app_shutdown.dart | 78 +++++++++++ .../lib/src/playback/playback_controller.dart | 111 ++++++++++++--- .../lib/src/screens/home/settings_page.dart | 4 +- apps/desktop/lib/src/screens/home/ui.dart | 4 + .../src/screens/home/update_indicator.dart | 130 ++++++++++++++---- .../lib/src/session/session_coordinator.dart | 11 ++ .../lib/src/shaders/shader_presets.dart | 8 ++ apps/desktop/lib/src/shell/app_shell.dart | 26 +++- apps/desktop/lib/src/shell/party_strip.dart | 79 +++++++---- apps/desktop/lib/src/state/settings.dart | 22 ++- .../lib/src/update/update_controller.dart | 44 +++--- .../lib/src/widgets/update_banner.dart | 2 +- apps/desktop/linux/runner/main.cc | 14 +- apps/desktop/linux/runner/my_application.cc | 29 ++++ apps/desktop/test/home_test.dart | 62 ++++++++- apps/desktop/test/shaders_mpv_test.dart | 8 +- apps/desktop/test/shaders_test.dart | 16 +++ apps/desktop/test/shell_test.dart | 34 ++++- apps/desktop/test/update_controller_test.dart | 20 +++ apps/desktop/test/volume_readout_test.dart | 97 +++++++++++++ .../desktop/windows/runner/flutter_window.cpp | 28 ++++ apps/desktop/windows/runner/flutter_window.h | 3 + apps/desktop/windows/runner/main.cpp | 9 ++ docs/ARCHITECTURE.md | 89 ++++++++++-- docs/DEVELOPMENT.md | 13 ++ docs/ROADMAP.md | 25 ++-- 29 files changed, 873 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/lib/src/platform/app_shutdown.dart create mode 100644 apps/desktop/test/volume_readout_test.dart diff --git a/README.md b/README.md index ebd9349..cd9c51c 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ From the Releases page of this repo: `watch_together.exe`. mpv is bundled. New and not yet tried on a real Windows desktop; reports welcome. -Update from the app: when a newer version exists, an update indicator -appears in the strip at the top of the window and opens the update card. "Update and restart" downloads, -verifies, applies in place and restarts; the install folder must be writable -by you. Settings has Check now and an auto-check toggle. From a terminal: +Update from the app: the app looks for a new version at start and every +few hours, and when one exists a band across the top of the window says so +(never over a playing video). "Update and restart" downloads, +verifies, applies in place and restarts; "Later" hides it until the next +check; the install folder must be writable by you. The version you run is +under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal: `./watch-together-server --upgrade` (no display needed, so it works on a server box too), `watch_together --upgrade`, or the shipped `update.sh` / `update.bat` (which run the first). Each updates the whole folder, app and @@ -93,8 +95,8 @@ right-click for Copy message and, with a link in it, Copy link / Open in browser), and its "Activity" toggle adds joins, ready, seeks and the like between them. Joins, leaves, chat and other news also pop up bottom left with the person's avatar; click a message to open the chat -(Settings → Notifications → Chat messages: Only when paused by default, -or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat +(Settings → Notifications → Chat messages: All by default, or Only when +paused, or Off; messages always reach the chat, with their sound). Unread chat shows as a count on the chat buttons and, in fullscreen with the controls hidden and the video paused, as a small chip top right. @@ -290,7 +292,8 @@ right of its controls, and Settings → Player has the same ones (plus the controls' layout and the custom shader files). Player design: Standard or Minimal (below); progress line when hidden (on by default: a thin line at the bottom of the video while the controls are hidden); upscaling shader -(see below); hardware decoding (auto, off or custom; the env var +(see below); hardware decoding (auto, off or custom; auto is mpv's +`auto-copy` on Windows, `auto` elsewhere; the env var `SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and audio track. Settings → Player → Controls: time display (elapsed / total or remaining), controls size (normal or large), hide the controls after diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 1e1241e..770bb50 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List 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 main(List 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 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()); } diff --git a/apps/desktop/lib/src/files/thumbnails.dart b/apps/desktop/lib/src/files/thumbnails.dart index baaa29d..17c0be5 100644 --- a/apps/desktop/lib/src/files/thumbnails.dart +++ b/apps/desktop/lib/src/files/thumbnails.dart @@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20); int _running = 0; final _waiting = >[]; final _inFlight = >{}; +final _players = {}; +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 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 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 _generate( bufferSize: 4 << 20, ), ); + _players.add(player); try { final mpv = player.platform as NativePlayer; for (final (k, v) in [ @@ -176,6 +198,7 @@ Future _generate( } return null; } finally { - await player.dispose(); + // [cancelAllThumbnails] may have taken it already. + if (_players.remove(player)) await player.dispose(); } } diff --git a/apps/desktop/lib/src/platform/app_shutdown.dart b/apps/desktop/lib/src/platform/app_shutdown.dart new file mode 100644 index 0000000..c7ccd46 --- /dev/null +++ b/apps/desktop/lib/src/platform/app_shutdown.dart @@ -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 shutDownApp({Future 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 _step(String what, Future? 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'); +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index ce4f507..fe7f0f4 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -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 setShaderValue(String value) async { + Future setShaderPaths(List 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 _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 disposeAll({Duration each = const Duration(milliseconds: 1500)}) async { + Future bounded(Future 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 diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index b8605f3..cb2af46 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -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, ), diff --git a/apps/desktop/lib/src/screens/home/ui.dart b/apps/desktop/lib/src/screens/home/ui.dart index 60b56bb..36e2f28 100644 --- a/apps/desktop/lib/src/screens/home/ui.dart +++ b/apps/desktop/lib/src/screens/home/ui.dart @@ -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; diff --git a/apps/desktop/lib/src/screens/home/update_indicator.dart b/apps/desktop/lib/src/screens/home/update_indicator.dart index 04058fe..be1c924 100644 --- a/apps/desktop/lib/src/screens/home/update_indicator.dart +++ b/apps/desktop/lib/src/screens/home/update_indicator.dart @@ -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 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. diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 1b9d315..a194c95 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -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 releasePlayers() async { + if (_playerReady) _lastSnap = _snapNow(); + _playerReady = false; + _ticker?.cancel(); + _ticker = null; + await playback.disposeAll(); + } + Future leave({bool silent = false}) async { _invalidateAttempts(); await _teardown(silent: silent); diff --git a/apps/desktop/lib/src/shaders/shader_presets.dart b/apps/desktop/lib/src/shaders/shader_presets.dart index 3a63f9f..f6f6a7a 100644 --- a/apps/desktop/lib/src/shaders/shader_presets.dart +++ b/apps/desktop/lib/src/shaders/shader_presets.dart @@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) => /// The `glsl-shaders` value for [paths]. String joinShaderPaths(List 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 paths, String? got) => + got != null && const [',', ';', ':'].any((sep) => paths.join(sep) == got); diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 0cad345..25bdac2 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -128,6 +128,14 @@ class AppShellState extends State 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 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 with SingleTickerProviderStateMixin if (mounted) f(); }); + Timer? _updateTimer; + + /// At start, then every [updateCheckEvery] while the app runs (when the + /// auto-check setting is on). Future _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 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 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( diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index f83871d..1e42fa8 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -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), + ), + ), + ), ), - ), ], ), ), diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 262f7d3..1c83095 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -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>(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? _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); diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart index 88648ea..3d951e3 100644 --- a/apps/desktop/lib/src/update/update_controller.dart +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -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 launchArgs = const []; -// Relaunch the (now replaced) binary detached with the same args, then -// exit this process. -Future restartApp(String executable, List 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 Function(String executable, List 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 _closeAndRestart(String executable, List 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) && diff --git a/apps/desktop/lib/src/widgets/update_banner.dart b/apps/desktop/lib/src/widgets/update_banner.dart index d8ccc11..9ca05da 100644 --- a/apps/desktop/lib/src/widgets/update_banner.dart +++ b/apps/desktop/lib/src/widgets/update_banner.dart @@ -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 { diff --git a/apps/desktop/linux/runner/main.cc b/apps/desktop/linux/runner/main.cc index e7c5c54..1bebb32 100644 --- a/apps/desktop/linux/runner/main.cc +++ b/apps/desktop/linux/runner/main.cc @@ -1,6 +1,16 @@ +#include +#include + #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); } diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc index d80f05d..80c53d9 100644 --- a/apps/desktop/linux/runner/my_application.cc +++ b/apps/desktop/linux/runner/my_application.cc @@ -1,5 +1,8 @@ #include "my_application.h" +#include +#include + #include #ifdef GDK_WINDOWING_X11 #include @@ -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(); diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index d18d123..d0aee30 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -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 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); + }); + }); } diff --git a/apps/desktop/test/shaders_mpv_test.dart b/apps/desktop/test/shaders_mpv_test.dart index 5602504..d0da2e6 100644 --- a/apps/desktop/test/shaders_mpv_test.dart +++ b/apps/desktop/test/shaders_mpv_test.dart @@ -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. diff --git a/apps/desktop/test/shaders_test.dart b/apps/desktop/test/shaders_test.dart index 18efac1..8d7e9da 100644 --- a/apps/desktop/test/shaders_test.dart +++ b/apps/desktop/test/shaders_test.dart @@ -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('//'); diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index ba2d1f7..748b37c 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -68,14 +68,14 @@ void main() { coord.toasts.value = []; }); - Future pumpShell(WidgetTester tester, Size size) async { + Future 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)); diff --git a/apps/desktop/test/update_controller_test.dart b/apps/desktop/test/update_controller_test.dart index 837ea56..06ee026 100644 --- a/apps/desktop/test/update_controller_test.dart +++ b/apps/desktop/test/update_controller_test.dart @@ -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}; diff --git a/apps/desktop/test/volume_readout_test.dart b/apps/desktop/test/volume_readout_test.dart new file mode 100644 index 0000000..e3c9c4d --- /dev/null +++ b/apps/desktop/test/volume_readout_test.dart @@ -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(find.ancestor(of: readout, matching: find.byType(AnimatedOpacity)).first).opacity; + Future 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(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(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)); + }); +} diff --git a/apps/desktop/windows/runner/flutter_window.cpp b/apps/desktop/windows/runner/flutter_window.cpp index bde63bc..6af950a 100644 --- a/apps/desktop/windows/runner/flutter_window.cpp +++ b/apps/desktop/windows/runner/flutter_window.cpp @@ -1,11 +1,30 @@ #include "flutter_window.h" +#include #include +#include #include #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 result = diff --git a/apps/desktop/windows/runner/flutter_window.h b/apps/desktop/windows/runner/flutter_window.h index a68609d..aa9f53d 100644 --- a/apps/desktop/windows/runner/flutter_window.h +++ b/apps/desktop/windows/runner/flutter_window.h @@ -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> window_channel_; + + // The first WM_CLOSE arms the exit watchdog. + bool close_requested_ = false; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/desktop/windows/runner/main.cpp b/apps/desktop/windows/runner/main.cpp index d103a7d..c1745af 100644 --- a/apps/desktop/windows/runner/main.cpp +++ b/apps/desktop/windows/runner/main.cpp @@ -2,6 +2,8 @@ #include #include +#include + #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; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 78bbb53..e2d34cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,7 +193,7 @@ Loosely modeled on Syncplay, with a different readiness design. the server state and reconnect every 1.5 s until cancelled (an epoch counter makes a cancelled attempt stale). After a reconnect the ready flag is restored from the server's state. Cancel during setup aborts the join. - Closing the window leaves gracefully (2 s timeout). + Closing the window leaves gracefully (see "App close" under Desktop UI). - **Idle** (`IdleTracker`): measured from the later of the last input and the moment playback stopped. While HELD and self ready: warning at 8 min, auto-unready at 10 min, and never less than 2 min after the warning. @@ -294,21 +294,29 @@ page yt-dlp knows", network errors → "Couldn't reach the site", anything else as is). It rides in `report.error` (see "Player error" in the sync model) until the media opens (a new duration) or the server's `mediaId` moves on; the pill shows "Can't play this: ..." with Retry (Reload -video). The cache is in RAM: -media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv +video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit +sets it on without a `cache-dir`, so mpv fell back to RAM anyway, warning +"Failed to create file cache." on every open). mpv ignores invalid values silently, so every pin is read back and a mismatch is logged and kept in `pinMismatches`. Measured against the `/m` endpoint (`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before). media_kit has no public `setProperty`/`getProperty`; the code calls them through a guarded dynamic call on `player.platform`. Hardware decoding comes from Settings (auto, off, custom) and is passed to the `VideoController`; env `SYNC_RELAY_HWDEC` (e.g. `no`) -overrides it. It is read per player, so a change applies on the next media, +overrides it. Auto is mpv `auto` on Linux and `auto-copy` on Windows +(`autoHwdec`, also for a blank custom value): media_kit renders through +ANGLE there, where `auto` picks the dxva2 interop, which cannot create its +EGL surface ("Failed to create EGL surface"); `auto-copy` decodes on the +GPU (d3d11va-copy, dxva2-copy, ... whichever works) and copies frames +back, needing no interop. It is read per player, so a change applies on the next media, join or Reload video. Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set through the same dynamic `setProperty` and read back (`PlaybackController.applyShaders`). The value is the platform path list -(`:` joined, `;` on Windows). One saved choice in `AppSettings` +(`:` joined, `;` on Windows). The read-back is compared as a list +(`shaderListMatches`): older mpv (0.37 and before, the bundled Windows +libmpv among them) prints it `,` joined. One saved choice in `AppSettings` (`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player creation (join, new media, Reload video) and live when changed from the session (the controls' ⚙ → Upscaling shader menu, Ctrl+0..6, both also save it). The Anime4K @@ -330,7 +338,17 @@ mpv logs at `v` while the controller is created: OpenGL init logs (`RenderMode.gpu`; "suspected software renderer" means llvmpipe, `softwareGl`); none within 1.5 s of the controller being ready means the software path (`software`, where shaders do nothing; the shader toast, the -menu and Settings say so). Otherwise the session player logs at `warn` +menu and Settings say so). Into the app log (`mpv`, INFO, all starting +`render`): once per app start on Linux `render env:` (`XDG_SESSION_TYPE`, +`WAYLAND_DISPLAY`, `DISPLAY`, `GDK_BACKEND`, `FLUTTER_LINUX_RENDERER`, +`LIBGL_ALWAYS_SOFTWARE`, `__EGL_VENDOR_LIBRARY_FILENAMES`, `-` when +unset); from that `v` window only mpv's GL init lines (`GL_VENDOR`, +`GL_RENDERER`, `GL_VERSION`, `GL_SHADING_LANGUAGE_VERSION`, `Detected +...`); the mode decided at the end of it; and per player, at its first +frame, `hwdec-current` and the `hwdec` asked for (the first one per app +start also `mpv-version` and `ffmpeg-version`). Why media_kit fell back to +software it only prints on stderr (`media_kit: VideoOutput: ...`), which +the app does not capture. Otherwise the session player logs at `warn` (`PlaybackController.logLevel`, switched with `mpv_request_log_messages` on media_kit's handle): media_kit drains mpv's log on the UI thread, one message at a time, so a chatty level, or ffmpeg logging every retry of a @@ -1105,7 +1123,8 @@ panel) and chat messages (Copy message, and a link in it). - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card; 42 px like the party's), the Library / Schedule / Settings tabs with a growing underline (an - `HScroll` when narrow), the update indicator, then offline a grey dot, + `HScroll` when narrow; the version in small text under Settings, + `shortVersion`, the full one in its tooltip), then offline a grey dot, "Offline", Join a friend and Host (connect cards; icon-less "Join" and "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends (the Host card, titled "Invite friends", "Start hosting") and Stop (no @@ -1270,7 +1289,7 @@ panel) and chat messages (Copy message, and a link in it). switched off in `soundsOff`. The device opens 2 s after startup (`warmUp`), so neither startup nor the first sound waits on it; if it can't open, the engine logs `no sounds: audio init failed` once and drops - every play. The close path disposes the engine (deinit, 500 ms bound) + every play. App close disposes the engine (deinit, 500 ms bound) before leaving the server. Settings → Notifications & sounds has the volume, the overall sound set (`soundPreset`), and per sound a preview, its own set (`soundPresetFor`, else the overall one) and a switch; a preview always @@ -1376,8 +1395,10 @@ panel) and chat messages (Copy message, and a link in it). own have none); each slides in, stays 5 s, then slides out and collapses its gap. A click on a chat toast opens the chat; the others let clicks through. Chat toasts - follow `AppSettings.chatPopups` (Settings → Notifications: All, Only - when paused (default), Off); the message is in the chat either way. + follow `AppSettings.chatPopups` (Settings → Notifications: All + (default), Only when paused, Off); the message is in the chat either + way, and its sound plays whatever this setting (its own switch in + Sounds). Settings persist in two Hive boxes: `connect_form` (last form, recent servers and links, the profile picture and colour; saved even when a join @@ -1534,6 +1555,36 @@ Window chrome is frameless (`window_manager`): the party strip is the drag area and holds the min/max/close buttons (`widgets/window_buttons.dart`); fullscreen has no drag area and shows the buttons in the player's top bar. +App close (`platform/app_shutdown.dart`, `shutDownApp`; the close button, +Alt+F4, and Update and restart): the window position is saved and the +window hides at once. Then every mpv player goes, before the engine does +(a live one calls its wakeup callback into a dead isolate): the session +player, the preload and the player still retiring +(`PlaybackController.disposeAll`, 1.5 s each, via +`SessionCoordinator.releasePlayers`, which keeps the position for the +Jellyfin stop report), the Files thumbnail jobs (`cancelAllThumbnails`) +and the sound engine (500 ms), side by side; on Windows 300 ms more, +since media_kit_video frees a video output on a thread of its own after +dispose returns, and an output still up when the engine stops blocks the +plugin's teardown (`~VideoOutput` waits on a texture callback that never +comes). Then `SessionState.leave` +(Jellyfin stop report, last diagnostics, the goodbye, so others see +"left") and the hosted server's stop, 3 s in all; an update restart then +closes Hive and starts the new binary. Last the window closes the normal +way (`setPreventClose(false)` + `close`), so the engine and plugins are +torn down inside the platform's main loop, and the runners end the +process without `exit()`'s handlers: Linux `_exit` after +`g_application_run` (NVIDIA's EGL exit handler segfaults inside `exit()`), +Windows `TerminateProcess` after the message loop (no DLL detach +handlers, which can hang on threads already gone). Every step is bounded +and logs (`close: ...`) when it is slow or fails. Backstops, so no process +stays behind a hidden window: 3 s after asking the window to close the +app kills itself (`Process.killPid`, no exit handlers; it only gets there +if the close did not go through); the runners end the process 15 s after +the first close request (Linux: a `delete-event` handler connected before +window_manager's; Windows: the first `WM_CLOSE`), and Windows also 3 s +after the window is destroyed, while the engine and plugins go. + ## Logging `packages/core/lib/src/logging.dart`, one setup for the app and the @@ -1690,10 +1741,20 @@ defines for every build and run) with "dev run". CLI: `watch-together-server --u the shipped `update.sh` / `update.bat` run it), `--check-update` prints only; it ends with "restart the server to run ". `watch_together --upgrade` does the same from the app binary. In the app, `UpdateController` -(`apps/desktop/lib/src/update/`) runs the same code for the strip's update -indicator, which opens the update card, and restarts the app ("Update and -restart", closing Hive first). -Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in +(`apps/desktop/lib/src/update/`) runs the same code for the update bar +(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the +window under the strip while an update is available: "Starsync X.Y.Z is +ready", the notes, Later (hidden until the next check or start) and +"Update and restart" (download, apply, then the app close above with Hive +closed and the new binary started), its progress in the band. A release +that is behind also says which version it runs; a copy that is not a +packaged install says to replace the folder by hand. The shell keeps the +band off the full player while a video is on (not finished); it shows on +the tabs, the mini player's views and once the video ends. A required +update has no Later. Nothing downloads before the click. The app checks at +start and every 4 hours while running (`updateCheckEvery`), both with the +auto-check setting on; a re-check never touches an install under way. +Settings has Check now and the auto-check toggle. The manifest is `updates/manifest.json` in this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`, `isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev` prerelease (`tool/dev_release.sh`), and take the newer of the two diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aeefeec..e998cd3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -176,6 +176,19 @@ headers), `mpv:` (`cannot open the media` and ffmpeg's lines), `yt-dlp:` the headers sent). A viewer that got no bytes while the source was slow to come up shows mpv's error with "no HTTP error logged". +For "the video is slow / shaders do nothing", grep `mpv: render` (what +each line holds: "Render mode" in [ARCHITECTURE.md](ARCHITECTURE.md)). +`render: gpu` with a real `GL_RENDERER` (not `llvmpipe` / `softpipe`) is +the good case; `render: softwareGl` is OpenGL on the CPU (Mesa's software +rasterizer: missing or blocked GPU drivers, `LIBGL_ALWAYS_SOFTWARE=1`); +`render: software` with no GL lines is media_kit's own fallback (it found +no usable EGL context: check `render env:` for an X11 session or a +`GDK_BACKEND` / `FLUTTER_LINUX_RENDERER` override). Its reason is only on +stderr: have the user start the app from a terminal +(`watch-together 2>&1 | grep VideoOutput`). `hwdec-current=no` on the +first-frame line means software decoding (no hwdec for that codec, or it +is off), not software rendering. + ## Gotchas - Relic gives Range as a typed `RangeHeader`; convert from unit + first range. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3c38894..e97a5b2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -52,8 +52,8 @@ Problems are numbered; delete a line when its fix ships. the player instead of being one media pipeline. - P18 Bilibili "no video formats" with browser cookies set; the hint wording misleads (D11). -- P20 Version not visible. P21 Updates easy to miss. P22 Friends on - different versions (D8). +- P22 Friends on different versions (D8). (P20 version visible and P21 + update band: fixed in 1.2.1-dev.3; check the band in real use.) - P23 Solo mode you enter and leave; going hosted loses the queue (D9). - P24 The invite address list is unhelpful (shows the proxy's 198.18.x). - P25 ssh to Gitea blocked through the proxy (pushes go over https). @@ -62,14 +62,19 @@ Problems are numbered; delete a line when its fix ships. - P28 Log noise: "Failed to create file cache"; an idle relay channel for Jellyfin items everyone has an account for. - P29 Relay start delay after a seek into an unbuffered part. -- P30 Shaders don't work on Windows (our read-back check turns them off; - dxva2-egl interop error). -- P31 Software rendering on a friend's Linux; render mode not logged. -- P32 The app freezes on exit, reported mostly by Windows users and - other friends; on the user's Linux/NVIDIA machine it shows only as - silent crash dumps after the window is gone (NVIDIA EGL exit handler; - mpv players alive at teardown; Windows close order). -- P33 No volume readout when using the mouse wheel on the video. +- P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back + check; Windows hwdec `auto-copy`): a Windows user must confirm shaders + stay on and the EGL-surface error is gone. +- P31 Software rendering on a friend's Linux. 1.2.1-dev.3 logs it + (`mpv: render` lines); needs that friend's log, plus + `watch-together 2>&1 | grep VideoOutput` from a terminal if software. +- P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3: + orderly close, Linux `_exit` (0 crashes in 41 closes vs 10/10 before), + Windows normal close + TerminateProcess + watchdogs, untested on + Windows; if it still hangs: Event Viewer 1000/1002, a Task Manager dump, + the `close:` log lines. +- P33 No volume readout on wheel: likely fixed by the controls rework + (1.2.1-dev.1; a test covers both skins); confirm in real use. - P34 Local anime folders are opened as plain files (no banner, names, next episode, tracking); test library: the user's `/mnt/torrents/Torrents/Binux/` (several shows and a movie).