Stabilisation quick fixes: orderly close, visible version and update band, chat pop-ups on, Windows shaders and hwdec, render logs
- Close: hide the window, dispose every player, thumbnail job and the sound engine, leave, then close normally; Linux runner _exit (the NVIDIA EGL exit handler crashed 10/10 closes, 0/41 now); Windows tears down inside the message loop, then TerminateProcess; watchdogs. - Version under the Settings tab; an update band instead of a dot, never over a video; checks at start and every 4 hours. - Chat pop-ups default to All again. - Shader read-back compared as a list (old mpv joins with ','), so Windows stops turning shaders off; Windows hwdec auto-copy. - Render diagnostics (`mpv: render` lines); RAM-only demuxer cache pin silences the file-cache warning; a volume readout test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e537f9d5f8
commit
ea607c1a52
29 files changed
+873
-168
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,28 +391,55 @@ 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(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 3),
|
Column(
|
||||||
Text(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
label,
|
children: [
|
||||||
style: TextStyle(
|
const SizedBox(height: 3),
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
Text(
|
||||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
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),
|
if (caption case final c?)
|
||||||
AnimatedContainer(
|
Positioned(
|
||||||
duration: easeTime,
|
left: 0,
|
||||||
curve: ease,
|
right: 0,
|
||||||
height: 3,
|
bottom: 2,
|
||||||
width: selected ? 22 : 0,
|
height: 13,
|
||||||
decoration: BoxDecoration(
|
child: OverflowBox(
|
||||||
color: scheme.primary,
|
maxWidth: 160,
|
||||||
borderRadius: BorderRadius.circular(2),
|
child: Text(
|
||||||
|
c,
|
||||||
|
key: const ValueKey('strip-version'),
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.2,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package: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 {
|
||||||
try {
|
final keep = args.where((a) => a != '--upgrade').toList();
|
||||||
await Hive.close();
|
await shutDownApp(beforeQuit: () async {
|
||||||
} catch (e) {
|
try {
|
||||||
_log.warning('closing hive before restart: $e');
|
await Hive.close();
|
||||||
}
|
} catch (e) {
|
||||||
await restartApp(executable, args);
|
_log.warning('closing hive before restart: $e');
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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('//');
|
||||||
|
|||||||
@@ -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_
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in new issue
Block a user