Stabilisation quick fixes: orderly close, visible version and update band, chat pop-ups on, Windows shaders and hwdec, render logs
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m50s

- 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:
foxyzandClaude Opus 5.5 committed 2026-10-15 18:01:44 +03:00
1 parent e537f9d5f8
commit ea607c1a52
29 files changed
+850 -145

No files matched your search

+10 -7
View File
@@ -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 `watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator Update from the app: the app looks for a new version at start and every
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads, few hours, and when one exists a band across the top of the window says so
verifies, applies in place and restarts; the install folder must be writable (never over a playing video). "Update and restart" downloads,
by you. Settings has Check now and an auto-check toggle. From a terminal: 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 `./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` / server box too), `watch_together --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and `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 browser), and its "Activity" toggle adds
joins, ready, seeks and the like between them. Joins, leaves, chat and 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 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, (Settings → Notifications → Chat messages: All by default, or Only when
or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat 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, buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right. 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 controls' layout and the custom shader files). Player design: Standard or
Minimal (below); progress line when hidden (on by default: a thin line at 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 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 `SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and
audio track. Settings → Player → Controls: time display (elapsed / total audio track. Settings → Player → Controls: time display (elapsed / total
or remaining), controls size (normal or large), hide the controls after or remaining), controls size (normal or large), hide the controls after
+7 -24
View File
@@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/playback/yt_dlp_app.dart'; import 'src/playback/yt_dlp_app.dart';
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/platform/window_memory.dart'; import 'src/platform/window_memory.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
@@ -48,7 +49,8 @@ Future<void> main(List<String> args) async {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
await windowManager.setPreventClose(true); await windowManager.setPreventClose(true);
final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null); final memory = WindowMemory(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
windowManager.addListener(_CloseGuard(memory)); shutdownWindowMemory = memory;
windowManager.addListener(_CloseGuard());
final windowOptions = WindowOptions( final windowOptions = WindowOptions(
size: memory.size, size: memory.size,
minimumSize: const Size(160, 90), 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); Future.delayed(const Duration(seconds: 2), SessionState.instance.sfx.warmUp);
} }
/// Close button / Alt+F4: leave gracefully first (others see "left", a /// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
/// hosted server shuts down), bounded so a hung socket can't keep the /// see "left", a hosted server shuts down and no player outlives the
/// window open. /// engine.
class _CloseGuard with WindowListener { class _CloseGuard with WindowListener {
final WindowMemory memory;
bool _closing = false;
_CloseGuard(this.memory);
@override @override
Future<void> onWindowClose() async { void onWindowClose() => unawaited(shutDownApp());
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();
}
} }
+25 -2
View File
@@ -33,15 +33,36 @@ const _timeout = Duration(seconds: 20);
int _running = 0; int _running = 0;
final _waiting = <Completer<void>>[]; final _waiting = <Completer<void>>[];
final _inFlight = <String, Future<Thumbnail?>>{}; 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 /// 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). /// when it can't be made (not a video, unreadable, timed out, cancelled).
/// Never throws. [cancelled] is checked while queued and before the /// Never throws. [cancelled] is checked while queued and before the
/// expensive steps: a scrolled-away tile returns null without decoding. /// expensive steps: a scrolled-away tile returns null without decoding.
Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) { Future<Thumbnail?> thumbnailFor(String path, {bool Function()? cancelled}) {
if (_closed) return Future.value(null);
final existing = _inFlight[path]; final existing = _inFlight[path];
if (existing != null) return existing; if (existing != null) return existing;
final f = _thumbnailFor(path, cancelled ?? () => false).catchError(( final f = _thumbnailFor(path, () => _closed || (cancelled?.call() ?? false)).catchError((
Object e, Object e,
StackTrace st, StackTrace st,
) { ) {
@@ -136,6 +157,7 @@ Future<Thumbnail?> _generate(
bufferSize: 4 << 20, bufferSize: 4 << 20,
), ),
); );
_players.add(player);
try { try {
final mpv = player.platform as NativePlayer; final mpv = player.platform as NativePlayer;
for (final (k, v) in [ for (final (k, v) in [
@@ -176,6 +198,7 @@ Future<Thumbnail?> _generate(
} }
return null; return null;
} finally { } 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; static const readAheadSecs = 150;
/// Forward demuxer cache. Holds [readAheadSecs] up to ~14 Mbit/s (a /// 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: /// 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; static const maxBytes = 256 * 1024 * 1024;
/// Back buffer (media_kit sets it to bufferSize too): enough for a short /// Back buffer (media_kit sets it to bufferSize too): enough for a short
/// seek back without holding another 256 MiB. /// seek back without holding another 256 MiB.
@@ -175,6 +177,7 @@ class PlaybackController {
final existing = _video; final existing = _video;
if (existing != null) return existing; if (existing != null) return existing;
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
_logRenderEnv();
_requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode _requestLog(player, 'v'); // back to [logLevel] by _detectRenderMode
// media_kit's video output sets hwdec itself on attach ('auto' unless // media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC. // configured), which used to override SYNC_RELAY_HWDEC.
@@ -253,6 +256,7 @@ class PlaybackController {
await _pin('cache-secs', '$readAheadSecs', on); await _pin('cache-secs', '$readAheadSecs', on);
await _pin('demuxer-max-bytes', '$ahead', on); await _pin('demuxer-max-bytes', '$ahead', on);
await _pin('demuxer-max-back-bytes', '$back', on); await _pin('demuxer-max-back-bytes', '$back', on);
await _pin('cache-on-disk', 'no', on);
final hwdec = _hwdecOverride; final hwdec = _hwdecOverride;
if (hwdec != null) { if (hwdec != null) {
await _pin('hwdec', hwdec, on); await _pin('hwdec', hwdec, on);
@@ -310,13 +314,12 @@ class PlaybackController {
} }
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem != null) paths = const []; if (problem != null) paths = const [];
final value = joinShaderPaths(paths); if (joinShaderPaths(paths) != _shaders || problem != null) {
if (value != _shaders || problem != null) { final took = await setShaderPaths(paths);
final got = await setShaderValue(value);
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
if (problem == null && got != value) { if (problem == null && !took) {
problem = 'mpv did not take them'; problem = 'mpv did not take them';
await setShaderValue(''); await setShaderPaths(const []);
} }
} }
if (problem != null) { if (problem != null) {
@@ -326,20 +329,23 @@ class PlaybackController {
} }
} }
/// Sets mpv `glsl-shaders` on the current player and returns the /// Sets mpv `glsl-shaders` to [paths] on the current player; true when
/// read-back (null on error). /// mpv's read-back is that list ([shaderListMatches]: old mpv prints it
/// joined with ',').
@visibleForTesting @visibleForTesting
Future<String?> setShaderValue(String value) async { Future<bool> setShaderPaths(List<String> paths) async {
final value = joinShaderPaths(paths);
try { try {
final dynamic platform = player.platform; final dynamic platform = player.platform;
await platform?.setProperty('glsl-shaders', value); await platform?.setProperty('glsl-shaders', value);
final got = '${await platform?.getProperty('glsl-shaders')}'; final got = '${await platform?.getProperty('glsl-shaders')}';
_shaders = got; final took = shaderListMatches(paths, got);
if (got != value) debugPrint('glsl-shaders=$value not applied: "$got"'); _shaders = took ? value : got;
return got; if (!took) debugPrint('glsl-shaders=$value not applied: "$got"');
return took;
} catch (e, st) { } catch (e, st) {
debugPrint('glsl-shaders=$value failed: $e\n$st'); debugPrint('glsl-shaders=$value failed: $e\n$st');
return null; return false;
} }
} }
@@ -390,6 +396,9 @@ class PlaybackController {
return; return;
} }
if (log.prefix != 'libmpv_render') 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')) { if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl); _setRenderMode(RenderMode.softwareGl);
} else if (log.text.startsWith('GL_VERSION') && } else if (log.text.startsWith('GL_VERSION') &&
@@ -402,7 +411,7 @@ class PlaybackController {
_shaders = ''; _shaders = '';
_shaderOp = _shaderOp.then((_) async { _shaderOp = _shaderOp.then((_) async {
if (!identical(p, _player)) return; if (!identical(p, _player)) return;
await setShaderValue(''); await setShaderPaths(const []);
_turnShadersOff('Shaders failed in mpv (${log.text}) — turned off'); _turnShadersOff('Shaders failed in mpv (${log.text}) — turned off');
}); });
} }
@@ -414,6 +423,52 @@ class PlaybackController {
static final _mpvLog = createLogger('mpv'); static final _mpvLog = createLogger('mpv');
static final _mpvThrottle = LogThrottle(burst: 5); 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 = ''}) { static void _toAppLog(PlayerLog log, {String player = ''}) {
final level = log.level; final level = log.level;
if (level != 'warn' && level != 'error' && level != 'fatal') return; if (level != 'warn' && level != 'error' && level != 'fatal') return;
@@ -451,11 +506,15 @@ class PlaybackController {
} finally { } finally {
if (identical(p, _player)) _requestLog(p, logLevel); 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; return;
} }
_setRenderMode(RenderMode.software); _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) { if (_shaders.isNotEmpty) {
_notice('Shaders have no effect: video uses software rendering'); _notice('Shaders have no effect: video uses software rendering');
} }
@@ -525,6 +584,8 @@ class PlaybackController {
_timeSubs.add(p.stream.completed.listen((done) { _timeSubs.add(p.stream.completed.listen((done) {
if (done && duration.value > Duration.zero) position.value = duration.value; 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) { _timeSubs.add(p.stream.buffer.listen((d) {
final ms = d.inMilliseconds; final ms = d.inMilliseconds;
buffered.value = Duration(milliseconds: ms - ms % 250); buffered.value = Duration(milliseconds: ms - ms % 250);
@@ -691,6 +752,24 @@ class PlaybackController {
} catch (_) {} } 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 // 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` // the length of a route pop (the fullscreen route holds its own `Video`
// and leaves animated). Bounded, since a hidden window renders no // and leaves animated). Bounded, since a hidden window renders no
@@ -198,8 +198,8 @@ class UpdateSettings extends StatelessWidget {
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'), key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Check for updates on start'), title: const Text('Check for updates automatically'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'), subtitle: const Text('When the app opens and every few hours; nothing installs without asking'),
value: s.autoCheckUpdates.value, value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates, onChanged: s.setAutoCheckUpdates,
), ),
@@ -22,6 +22,10 @@ String displayVersion({String version = appVersion, String pubspec = _pubspecVer
return isDevBuild(version) && commit.isNotEmpty ? '$version · $commit' : version; 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 kRadius = 12.0;
const kGap = 16.0; const kGap = 16.0;
@@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show isDevBuild;
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart'; import '../../widgets/update_banner.dart';
import 'ui.dart'; import 'ui.dart';
/// Compact "Update vX" pill for the party strip; hidden until there is /// The band across the top of the window while an update is up for
/// something to install. Click opens the full [UpdateBanner]. /// grabs: "Starsync 1.2.1 is ready", its notes, Update and restart and
class UpdateIndicator extends StatelessWidget { /// 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; final UpdateController? controller;
const UpdateIndicator({super.key, this.controller}); const UpdateBar({super.key, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,34 +24,102 @@ class UpdateIndicator extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final scheme = Theme.of(context).colorScheme; 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 || final busy = s.phase == UpdatePhase.downloading ||
s.phase == UpdatePhase.applying || s.phase == UpdatePhase.applying ||
s.phase == UpdatePhase.restarting; s.phase == UpdatePhase.restarting;
final label = switch (s.phase) { return Material(
UpdatePhase.downloading => key: const ValueKey('update-bar'),
'Updating ${((s.progress ?? 0) * 100).round()}%', color: alert ? scheme.errorContainer : scheme.primaryContainer,
UpdatePhase.applying => 'Installing…', child: Padding(
UpdatePhase.restarting => 'Restarting…', padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
UpdatePhase.failed => 'Update failed', child: Row(
_ => m.isRequired ? 'Required update' : 'Update v${m.version}', children: [
}; if (busy)
return Tooltip( SizedBox.square(
message: 'Show update', dimension: 18,
child: FilledButton.tonalIcon( child: CircularProgressIndicator(
style: FilledButton.styleFrom( strokeWidth: 2,
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed color: fg,
? scheme.errorContainer value: s.phase == UpdatePhase.downloading ? s.progress : null,
: null, ),
visualDensity: VisualDensity.compact, )
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 } enum UpdateCheck { notRun, checking, done }
/// Progress of this run's update check, for Settings/About wording. /// Progress of this run's update check, for Settings/About wording.
@@ -2242,6 +2242,17 @@ class SessionCoordinator {
return 'connect failed: $e'; 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 { Future<void> leave({bool silent = false}) async {
_invalidateAttempts(); _invalidateAttempts();
await _teardown(silent: silent); await _teardown(silent: silent);
@@ -85,3 +85,11 @@ String shaderListSeparator({bool? windows}) =>
/// The `glsl-shaders` value for [paths]. /// The `glsl-shaders` value for [paths].
String joinShaderPaths(List<String> paths, {bool? windows}) => String joinShaderPaths(List<String> paths, {bool? windows}) =>
paths.join(shaderListSeparator(windows: 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);
+25 -1
View File
@@ -128,6 +128,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
SessionState.instance.route.value == AppRoute.session && SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null); 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 mode => _mode;
PlayerMode get shownMode => _shown; PlayerMode get shownMode => _shown;
bool get fullscreen => _fullscreen; bool get fullscreen => _fullscreen;
@@ -226,10 +234,12 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
stop(); stop();
} }
HardwareKeyboard.instance.removeHandler(_onKey); HardwareKeyboard.instance.removeHandler(_onKey);
_updateTimer?.cancel();
_fade.dispose(); _fade.dispose();
_online.dispose(); _online.dispose();
_alone.dispose(); _alone.dispose();
_hasMedia.dispose(); _hasMedia.dispose();
_watching.dispose();
_flow.dispose(); _flow.dispose();
_join.dispose(); _join.dispose();
_host.dispose(); _host.dispose();
@@ -245,9 +255,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (mounted) f(); 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 { Future<void> _autoCheck() async {
final s = AppSettings.instance; final s = AppSettings.instance;
await s.ensureLoaded(); 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); if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
} }
@@ -616,6 +634,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final online = _online.value; final online = _online.value;
final alone = _alone.value; final alone = _alone.value;
final hasMedia = _hasMedia.value; final hasMedia = _hasMedia.value;
final onPlayer = _shown == PlayerMode.full && _watching.value;
return Column( return Column(
children: [ children: [
if (!_fullscreen) if (!_fullscreen)
@@ -632,9 +651,14 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onInvite: () => _connect(ConnectMode.host), onInvite: () => _connect(ConnectMode.host),
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates), onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null, onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
windowControls: widget.windowControls, windowControls: widget.windowControls,
), ),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: _fullscreen || onPlayer ? const SizedBox(width: double.infinity) : UpdateBar(controller: widget.updates),
),
Expanded( Expanded(
key: const ValueKey('body'), key: const ValueKey('body'),
child: Row( child: Row(
+35 -8
View File
@@ -5,11 +5,9 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../screens/home/update_indicator.dart';
import '../session/delivery.dart'; import '../session/delivery.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart'; import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart'; import '../widgets/app_menu.dart';
import '../widgets/copy_open.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. /// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer; final VoidCallback? onShowPlayer;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager. /// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls; final bool windowControls;
@@ -73,7 +70,6 @@ class PartyStrip extends StatelessWidget {
required this.onProfile, required this.onProfile,
this.onInvite, this.onInvite,
this.onShowPlayer, this.onShowPlayer,
this.updates,
this.windowControls = true, this.windowControls = true,
}); });
@@ -125,8 +121,10 @@ class PartyStrip extends StatelessWidget {
label: t.label, label: t.label,
selected: t == tab, selected: t == tab,
compact: compact, 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, tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
caption: t == ShellTab.settings ? shortVersion() : null,
onTap: () => onTab(t), onTap: () => onTab(t),
), ),
], ],
@@ -135,7 +133,6 @@ class PartyStrip extends StatelessWidget {
), ),
), ),
), ),
UpdateIndicator(controller: updates),
const SizedBox(width: 8), const SizedBox(width: 8),
if (online && onShowPlayer != null) if (online && onShowPlayer != null)
IconButton( IconButton(
@@ -369,6 +366,8 @@ class StripTab extends StatelessWidget {
final VoidCallback onTap; final VoidCallback onTap;
/// Shown after resting on the tab. /// Shown after resting on the tab.
final String? tooltip; final String? tooltip;
/// Small text under the tab (the version under Settings).
final String? caption;
const StripTab({ const StripTab({
super.key, super.key,
required this.label, required this.label,
@@ -376,6 +375,7 @@ class StripTab extends StatelessWidget {
required this.onTap, required this.onTap,
this.compact = false, this.compact = false,
this.tooltip, this.tooltip,
this.caption,
}); });
@override @override
@@ -391,7 +391,11 @@ class StripTab extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12), padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox( child: SizedBox(
height: partyStripHeight, height: partyStripHeight,
child: Column( child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 3), const SizedBox(height: 3),
@@ -415,6 +419,29 @@ class StripTab extends StatelessWidget {
), ),
], ],
), ),
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),
),
),
),
),
],
),
), ),
), ),
), ),
+15 -7
View File
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -40,6 +42,12 @@ const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom } 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). /// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups { enum ChatPopups {
all('All'), all('All'),
@@ -88,8 +96,8 @@ class AppSettings {
/// Send this app's log lines to the server's session log while in a /// 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. /// session (never chat). Off: nothing is sent but a note that it is off.
final shareDiagnostics = signal(true); final shareDiagnostics = signal(true);
/// Chat message toasts (default: only when paused). Applies live. /// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.paused); final chatPopups = signal(ChatPopups.all);
/// App sounds (`sfx/`): 0..1, 0 = silent. /// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0); final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one. /// `SfxKind` names switched off one by one.
@@ -109,13 +117,13 @@ class AppSettings {
/// .glsl files of [ShaderPreset.custom], in mpv order. /// .glsl files of [ShaderPreset.custom], in mpv order.
final customShaders = signal<List<String>>(const []); final customShaders = signal<List<String>>(const []);
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when /// mpv `hwdec` value: [autoHwdec], 'no', or the custom value
/// custom is blank). /// ([autoHwdec] when custom is blank).
late final hwdec = computed(() => switch (hwdecMode.value) { late final hwdec = computed(() => switch (hwdecMode.value) {
HwdecMode.auto => 'auto', HwdecMode.auto => autoHwdec,
HwdecMode.off => 'no', HwdecMode.off => 'no',
HwdecMode.custom => HwdecMode.custom =>
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(), hwdecCustom.value.trim().isEmpty ? autoHwdec : hwdecCustom.value.trim(),
}); });
Future<void>? _load; Future<void>? _load;
@@ -160,7 +168,7 @@ class AppSettings {
final share = box.get(_keyShareDiagnostics); final share = box.get(_keyShareDiagnostics);
if (share is bool) shareDiagnostics.value = share; if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups); 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); final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff); final off = box.get(_keySoundsOff);
@@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.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 // `--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 // UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install. // there is something to install.
@@ -28,26 +30,23 @@ class UpdateStatus {
// Process args as main() received them — restart relaunches with these. // Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const []; 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); 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 { Future<void> _closeAndRestart(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await shutDownApp(beforeQuit: () async {
try { try {
await Hive.close(); await Hive.close();
} catch (e) { } catch (e) {
_log.warning('closing hive before restart: $e'); _log.warning('closing hive before restart: $e');
} }
await restartApp(executable, args); await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
});
} }
class UpdateController { class UpdateController {
@@ -115,15 +114,22 @@ class UpdateController {
return; return;
} }
_log.info('update available: ${m.version} (proto ${m.protocol})'); _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, status.value = UpdateStatus(UpdatePhase.available,
manifest: m, canInstall: root != null); manifest: m, canInstall: root != null);
lastCheck.value = 'Update v${m.version} available';
} catch (e) { } catch (e) {
_log.warning('update check failed: $e'); _log.warning('update check failed: $e');
lastCheck.value = '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() { void dismiss() {
final s = status.value; final s = status.value;
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) && 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'; 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 // background check on first build; renders nothing until there is an
// update to offer. Never shown over playback. // update to offer. Never shown over playback.
class UpdateBanner extends StatefulWidget { class UpdateBanner extends StatefulWidget {
+12 -2
View File
@@ -1,6 +1,16 @@
#include <stdio.h>
#include <unistd.h>
#include "my_application.h" #include "my_application.h"
int main(int argc, char** argv) { int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new(); MyApplication* app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv); 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 "my_application.h"
#include <stdio.h>
#include <unistd.h>
#include <flutter_linux/flutter_linux.h> #include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11 #ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h> #include <gdk/gdkx.h>
@@ -38,6 +41,28 @@ static void window_method_cb(FlMethodChannel* channel, FlMethodCall* call,
fl_method_call_respond(call, response, nullptr); 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, static gboolean window_focus_in_cb(GtkWidget* widget, GdkEvent* event,
gpointer user_data) { gpointer user_data) {
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE); gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
@@ -111,6 +136,10 @@ static void my_application_activate(GApplication* application) {
self); self);
gtk_widget_realize(GTK_WIDGET(view)); 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)); fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
+59 -3
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.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/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/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint; import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
import 'package:watch_together/src/state/session_state.dart'; 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', () { test('hwdec value follows mode', () {
final s = AppSettings(); final s = AppSettings();
expect(s.hwdec.value, 'auto'); expect(s.hwdec.value, autoHwdec);
expect(autoHwdec, Platform.isWindows ? 'auto-copy' : 'auto');
s.setHwdecMode(HwdecMode.off); s.setHwdecMode(HwdecMode.off);
expect(s.hwdec.value, 'no'); expect(s.hwdec.value, 'no');
s.setHwdecMode(HwdecMode.custom); s.setHwdecMode(HwdecMode.custom);
expect(s.hwdec.value, 'auto'); expect(s.hwdec.value, autoHwdec);
s.setHwdecCustom(' vaapi '); s.setHwdecCustom(' vaapi ');
expect(s.hwdec.value, 'vaapi'); expect(s.hwdec.value, 'vaapi');
}); });
@@ -150,7 +157,7 @@ void main() {
expect(find.text('Hardware decoding'), findsOneWidget); expect(find.text('Hardware decoding'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-updates'))); await tester.tap(find.byKey(const ValueKey('settings-updates')));
await tester.pumpAndSettle(); 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.tap(find.byKey(const ValueKey('settings-shortcuts')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Toggle ready'), findsOneWidget); expect(find.text('Toggle ready'), findsOneWidget);
@@ -288,4 +295,53 @@ void main() {
expect(tester.takeException(), isNull, reason: source); 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);
});
});
} }
+4 -4
View File
@@ -27,9 +27,9 @@ void main() {
s.setShaderPreset(ShaderPreset.a); s.setShaderPreset(ShaderPreset.a);
await p.init(); await p.init();
final dynamic mpv = p.player.platform; final dynamic mpv = p.player.platform;
final modeA = joinShaderPaths( final modeA =
await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const [])); await shaderPathsFor(ShaderPreset.a, ShaderQuality.hq, const []);
expect(await mpv.getProperty('glsl-shaders'), modeA); expect(shaderListMatches(modeA, await mpv.getProperty('glsl-shaders')), isTrue);
expect(p.shaderNotice.value, isNull); expect(p.shaderNotice.value, isNull);
// Live change, announced. // Live change, announced.
@@ -39,7 +39,7 @@ void main() {
final caFast = await shaderPathsFor( final caFast = await shaderPathsFor(
ShaderPreset.ca, ShaderQuality.fast, const []); ShaderPreset.ca, ShaderQuality.fast, const []);
expect(caFast.first, startsWith(support.path)); 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)'); expect(p.shaderNotice.value?.text, 'Shaders: Mode C+A (Fast)');
// A missing custom file: toast, off, nothing set. // A missing custom file: toast, off, nothing set.
+16
View File
@@ -70,6 +70,22 @@ void main() {
expect(joinShaderPaths([], windows: false), ''); 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', () { test('path problems: missing file, separator inside a path', () {
final d = _fresh('shaders_problem'); final d = _fresh('shaders_problem');
final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//'); final ok = File('${d.path}/ok.glsl')..writeAsStringSync('//');
+32 -2
View File
@@ -68,14 +68,14 @@ void main() {
coord.toasts.value = []; 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.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: AppShell( home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'), updates: updates ?? UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false, autoUpdateCheck: false,
windowControls: false, windowControls: false,
videoBuilder: (context, controls) => const FakeVideo(), videoBuilder: (context, controls) => const FakeVideo(),
@@ -93,6 +93,36 @@ void main() {
member('f', 'Finn'), 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', () { group('party strip', () {
testWidgets('offline: Join and Host, no party', (tester) async { testWidgets('offline: Join and Host, no party', (tester) async {
await pumpShell(tester, const Size(1280, 800)); await pumpShell(tester, const Size(1280, 800));
@@ -205,6 +205,26 @@ void main() {
expect(dev.status.value.phase, UpdatePhase.idle); 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', test('failed install shows error, install untouched, retry works',
() async { () async {
server.manifestExtra = {'sha256': '0' * 64}; 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 "flutter_window.h"
#include <cstdio>
#include <optional> #include <optional>
#include <thread>
#include <flutter/standard_method_codec.h> #include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.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) FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {} : project_(project) {}
@@ -60,6 +79,8 @@ bool FlutterWindow::OnCreate() {
} }
void FlutterWindow::OnDestroy() { void FlutterWindow::OnDestroy() {
// The engine and the plugins go now; the app released its players first.
ExitAfter(3000);
window_channel_ = nullptr; window_channel_ = nullptr;
if (flutter_controller_) { if (flutter_controller_) {
flutter_controller_ = nullptr; flutter_controller_ = nullptr;
@@ -72,6 +93,13 @@ LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message, FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam, WPARAM const wparam,
LPARAM const lparam) noexcept { 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. // Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) { if (flutter_controller_) {
std::optional<LRESULT> result = std::optional<LRESULT> result =
@@ -34,6 +34,9 @@ class FlutterWindow : public Win32Window {
// "starsync/window": `attention` flashes the taskbar entry while the // "starsync/window": `attention` flashes the taskbar entry while the
// window is not in front (lib/src/platform/attention.dart). // window is not in front (lib/src/platform/attention.dart).
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> window_channel_; 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_ #endif // RUNNER_FLUTTER_WINDOW_H_
+9
View File
@@ -2,6 +2,8 @@
#include <flutter/flutter_view_controller.h> #include <flutter/flutter_view_controller.h>
#include <windows.h> #include <windows.h>
#include <cstdio>
#include "flutter_window.h" #include "flutter_window.h"
#include "utils.h" #include "utils.h"
@@ -38,6 +40,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
::DispatchMessage(&msg); ::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(); ::CoUninitialize();
::TerminateProcess(::GetCurrentProcess(), EXIT_SUCCESS);
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
+75 -14
View File
@@ -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 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 a cancelled attempt stale). After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join. 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 - **Idle** (`IdleTracker`): measured from the later of the last input and the
moment playback stopped. While HELD and self ready: warning at 8 min, 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. 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 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` 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 moves on; the pill shows "Can't play this: ..." with Retry (Reload
video). The cache is in RAM: video). The cache is in RAM: `cache-on-disk=no` is pinned (media_kit
media_kit sets `cache-on-disk` without a `cache-dir`, and mpv falls back. mpv 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 ignores invalid values silently, so every pin is read back and a mismatch is
logged and kept in `pinMismatches`. Measured against the `/m` endpoint logged and kept in `pinMismatches`. Measured against the `/m` endpoint
(`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before). (`readahead_e2e_test`): a paused player buffers ~147 s ahead (10 s before).
media_kit has no public `setProperty`/`getProperty`; the code calls them 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) 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`) 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. join or Reload video.
Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set Shaders (per viewer, local only, no wire change): mpv `glsl-shaders`, set
through the same dynamic `setProperty` and read back through the same dynamic `setProperty` and read back
(`PlaybackController.applyShaders`). The value is the platform path list (`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 (`shaderPreset`, `shaderQuality`, `customShaders`): applied on every player
creation (join, new media, Reload video) and live when changed from the session (the 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 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, (`RenderMode.gpu`; "suspected software renderer" means llvmpipe,
`softwareGl`); none within 1.5 s of the controller being ready means the `softwareGl`); none within 1.5 s of the controller being ready means the
software path (`software`, where shaders do nothing; the shader toast, 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` (`PlaybackController.logLevel`, switched with `mpv_request_log_messages`
on media_kit's handle): media_kit drains mpv's log on the UI thread, one 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 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), - **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 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 "Offline", Join a friend and Host (connect cards; icon-less "Join" and
"Host" under 1100 px), solo a "Solo · only you" chip, Invite friends "Host" under 1100 px), solo a "Solo · only you" chip, Invite friends
(the Host card, titled "Invite friends", "Start hosting") and Stop (no (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 switched off in `soundsOff`. The device opens 2 s after startup
(`warmUp`), so neither startup nor the first sound waits on it; if it (`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 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 before leaving the server. Settings → Notifications & sounds has the volume, the
overall sound set (`soundPreset`), and per sound a preview, its own set overall sound set (`soundPreset`), and per sound a preview, its own set
(`soundPresetFor`, else the overall one) and a switch; a preview always (`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 own have none); each
slides in, stays 5 s, then slides out and collapses its gap. A click on 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 a chat toast opens the chat; the others let clicks through. Chat toasts
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only follow `AppSettings.chatPopups` (Settings → Notifications: All
when paused (default), Off); the message is in the chat either way. (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 Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join 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`); 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. 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 ## Logging
`packages/core/lib/src/logging.dart`, one setup for the app and the `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; the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `watch_together --upgrade` it ends with "restart the server to run <version>". `watch_together --upgrade`
does the same from the app binary. In the app, `UpdateController` 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 (`apps/desktop/lib/src/update/`) runs the same code for the update bar
indicator, which opens the update card, and restarts the app ("Update and (`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
restart", closing Hive first). window under the strip while an update is available: "Starsync X.Y.Z is
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in 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`, this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`,
`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev` `isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev`
prerelease (`tool/dev_release.sh`), and take the newer of the two prerelease (`tool/dev_release.sh`), and take the newer of the two
+13
View File
@@ -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 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". 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 ## Gotchas
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range. - Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
+15 -10
View File
@@ -52,8 +52,8 @@ Problems are numbered; delete a line when its fix ships.
the player instead of being one media pipeline. the player instead of being one media pipeline.
- P18 Bilibili "no video formats" with browser cookies set; the hint - P18 Bilibili "no video formats" with browser cookies set; the hint
wording misleads (D11). wording misleads (D11).
- P20 Version not visible. P21 Updates easy to miss. P22 Friends on - P22 Friends on different versions (D8). (P20 version visible and P21
different versions (D8). 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). - 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). - 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). - 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 - P28 Log noise: "Failed to create file cache"; an idle relay channel for
Jellyfin items everyone has an account for. Jellyfin items everyone has an account for.
- P29 Relay start delay after a seek into an unbuffered part. - 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; - P30 Shaders don't work on Windows. Fix in 1.2.1-dev.3 (list read-back
dxva2-egl interop error). check; Windows hwdec `auto-copy`): a Windows user must confirm shaders
- P31 Software rendering on a friend's Linux; render mode not logged. stay on and the EGL-surface error is gone.
- P32 The app freezes on exit, reported mostly by Windows users and - P31 Software rendering on a friend's Linux. 1.2.1-dev.3 logs it
other friends; on the user's Linux/NVIDIA machine it shows only as (`mpv: render` lines); needs that friend's log, plus
silent crash dumps after the window is gone (NVIDIA EGL exit handler; `watch-together 2>&1 | grep VideoOutput` from a terminal if software.
mpv players alive at teardown; Windows close order). - P32 The app freezes on exit, mostly reported on Windows. 1.2.1-dev.3:
- P33 No volume readout when using the mouse wheel on the video. 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, - P34 Local anime folders are opened as plain files (no banner, names,
next episode, tracking); test library: the user's next episode, tracking); test library: the user's
`/mnt/torrents/Torrents/Binux/` (several shows and a movie). `/mnt/torrents/Torrents/Binux/` (several shows and a movie).