Desktop: video freeze recovery, session fixes, Home redesign

- Player + VideoController per session; Reload video (Ctrl+R) and a
  frozen-picture watchdog that reloads automatically.
- Shortcuts no longer fire while typing; empty swap disabled;
  hwdec setting/env actually applied.
- Idle unready measured from real inactivity; cancellable reconnects
  and setup; ready restored from server state; kicked/failed host
  stops its server; graceful leave on window close; no URLs with
  keys in display names.
- Welcome + connect screens replaced by one Home screen with Join and
  Host cards, recent servers, Settings, Integrations and About.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:41:02 +03:00
1 parent beae87b0cc
commit aeff087c7d
30 files changed
+3278 -601

No files matched your search

@@ -0,0 +1,120 @@
import 'dart:typed_data';
/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an
/// image. Used on a small capture of what the compositor shows: the
/// centre is video, the edges carry the (changing) control overlays.
int hashCenterPixels(ByteData rgba, int width, int height) {
var x = 0x811c9dc5;
if (width <= 0 || height <= 0) return x;
for (var gy = 1; gy < 16; gy++) {
for (var gx = 1; gx < 16; gx++) {
final px = (width * (0.25 + 0.5 * gx / 16)).floor();
final py = (height * (0.25 + 0.5 * gy / 16)).floor();
final o = (py * width + px) * 4;
if (o + 3 > rgba.lengthInBytes) continue;
for (var c = 0; c < 3; c++) {
x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff;
}
}
}
return x;
}
/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv
/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart.
int hashSpread(Uint8List bytes) {
var x = 0x811c9dc5;
if (bytes.isEmpty) return x;
final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096;
for (var i = 0; i < bytes.length; i += step) {
x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff;
}
return x;
}
/// One sample of player state, taken about once a second.
class FrameSample {
final DateTime at;
final double position;
/// Fingerprint of the picture currently shown on screen; null when it
/// cannot be captured (no view, hidden window, headless).
final int? frames;
/// Playing, not buffering, rate > 0, video track present, window
/// visible. Anything else disarms the watchdog.
final bool eligible;
const FrameSample({
required this.at,
required this.position,
required this.frames,
required this.eligible,
});
}
/// Detects a frozen picture: position advances normally but the shown
/// picture stands still for [stallAfter]. Seeks (position jumps), pauses,
/// buffering and missing captures all reset the stall clock. A hit is a
/// suspicion only — a still scene looks the same — so the caller confirms
/// it against the decoder and calls [dismiss] when it was a still scene.
/// Fires at most once until the picture is seen moving again for
/// [rearmAfter], so a player that stays (or soon gets) frozen after a
/// reload does not reload in a loop. [reset] keeps that latch.
class FrameWatchdog {
static const stallAfter = Duration(seconds: 3);
static const rearmAfter = Duration(seconds: 10);
FrameSample? _last;
DateTime? _stallSince;
DateTime? _healthySince;
bool _fired = false;
void reset() {
_last = null;
_stallSince = null;
_healthySince = null;
}
/// The last hit was a still scene: re-arm, but wait a full
/// [stallAfter] before suspecting again.
void dismiss() {
_fired = false;
_stallSince = null;
}
/// Returns true exactly when a freeze is suspected.
bool feed(FrameSample s) {
final last = _last;
_last = s;
if (!s.eligible || s.frames == null || last == null ||
!last.eligible || last.frames == null) {
_stallSince = null;
_healthySince = null;
return false;
}
if (s.frames != last.frames) {
_stallSince = null;
_healthySince ??= last.at;
if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false;
return false;
}
_healthySince = null;
final wall = s.at.difference(last.at).inMilliseconds / 1000.0;
final moved = s.position - last.position;
// Normal playback only: a jump (seek) or a standstill (stall, the
// position itself stuck) is not a frozen picture.
final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5;
if (!normal) {
_stallSince = null;
return false;
}
_stallSince ??= last.at;
if (_fired) return false;
if (s.at.difference(_stallSince!) >= stallAfter) {
_fired = true;
_stallSince = null;
return true;
}
return false;
}
}
@@ -1,23 +1,93 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/settings.dart';
import '../sync/room_diff.dart';
import 'frame_watchdog.dart';
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
/// [recreate] for "Reload video". Never reuse a player across sessions —
/// a GPU-side freeze sticks to the player, not to the widget.
class PlaybackController {
late final Player player;
bool _ready = false;
Player? _player;
VideoController? _video;
Timer? _watchTimer;
bool _probing = false;
final FrameWatchdog _watchdog = FrameWatchdog();
/// Bumped for every new player; the video view keys itself on it so a
/// recreated player gets a fresh `Video` widget.
final generation = signal(0);
/// True while a live player exists. The video view shows plain black
/// otherwise, so no texture outlives its player.
final alive = signal(false);
/// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen;
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame;
Player get player => _player!;
bool get _ready => _player != null;
/// The [VideoController] of the current player. Created lazily by the
/// video view (it needs a running Flutter binding; headless tests never
/// touch it) and released with the player.
VideoController get video {
final existing = _video;
if (existing != null) return existing;
final hwdec = _hwdecOverride;
// media_kit's video output sets hwdec itself on attach ('auto' unless
// configured), which used to override SYNC_RELAY_HWDEC.
return _video = VideoController(
player,
configuration: VideoControllerConfiguration(hwdec: hwdec),
);
}
// SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a
// changed setting applies on the next join or Reload video.
static String? get _hwdecOverride {
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
if (hwdec != null && hwdec.isNotEmpty) return hwdec;
return AppSettings.instance.hwdec.peek();
}
Future<void> init() async {
player = Player(
if (_player != null) return;
_player = Player(
configuration: PlayerConfiguration(
bufferSize: 64 * 1024 * 1024,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'],
));
_ready = true;
await _pinBufferProps();
_watchdog.reset();
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
generation.value++;
alive.value = true;
}
/// Dispose the current player and start a fresh one (no media loaded).
/// Keeps volume. The caller reopens media and resyncs.
Future<void> recreate() async {
final volume = _player?.state.volume;
await dispose();
await init();
if (volume != null) {
try {
await player.setVolume(volume);
} catch (_) {}
}
}
Future<void> _pin(String property, String value) async {
@@ -32,14 +102,81 @@ class PlaybackController {
Future<void> _pinBufferProps() async {
await _pin('demuxer-readahead-secs', '10');
await _pin('cache-secs', '10');
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
if (hwdec != null && hwdec.isNotEmpty) {
final hwdec = _hwdecOverride;
if (hwdec != null) {
await _pin('hwdec', hwdec);
}
await _pin('stream-lavf-o',
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
}
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
/// off-thread); null without video.
Future<int?> _sourceFrame(Player p) async {
try {
final raw = await p.screenshot(format: null);
return raw == null || raw.isEmpty ? null : hashSpread(raw);
} catch (_) {
return null;
}
}
/// A suspected freeze is real only if mpv's own frames still move: a
/// still scene looks frozen on screen too.
Future<bool> _sourceMoving(Player p) async {
final a = await _sourceFrame(p);
await Future.delayed(const Duration(milliseconds: 500));
if (!identical(p, _player) || !p.state.playing) return false;
final b = await _sourceFrame(p);
return a != null && b != null && a != b;
}
static bool get _windowVisible {
try {
final s = WidgetsBinding.instance.lifecycleState;
return s == null ||
s == AppLifecycleState.resumed ||
s == AppLifecycleState.inactive;
} catch (_) {
return false;
}
}
Future<void> _watch() async {
final p = _player;
final probe = shownFrame;
// Only meaningful once the video view is up.
if (p == null || _video == null || probe == null || _probing) return;
_probing = true;
try {
final frames = await probe();
if (!identical(p, _player)) return;
final s = p.state;
final eligible = s.playing &&
!s.buffering &&
!s.completed &&
s.rate > 0 &&
(s.width ?? 0) > 0 &&
_windowVisible;
final fired = _watchdog.feed(FrameSample(
at: DateTime.now(),
position: positionSecs,
frames: frames,
eligible: eligible,
));
if (fired) {
if (await _sourceMoving(p) && identical(p, _player)) {
debugPrint('video watchdog: picture stuck while mpv frames move');
onFrozen?.call();
} else {
_watchdog.dismiss();
}
}
} finally {
_probing = false;
}
}
Future<void> openUrl(String url) async {
if (!_ready) return;
try {
@@ -50,20 +187,28 @@ class PlaybackController {
}
}
double get positionSecs =>
player.state.position.inMilliseconds / 1000.0;
double get positionSecs {
final p = _player;
if (p == null) return 0;
return p.state.position.inMilliseconds / 1000.0;
}
double get bufferedSecsAhead {
final ahead =
(player.state.buffer - player.state.position).inMilliseconds / 1000.0;
final p = _player;
if (p == null) return 0;
final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0;
if (ahead.isNaN || ahead < 0) return 0;
return ahead;
}
double get durationSecs =>
player.state.duration.inMilliseconds / 1000.0;
double get durationSecs {
final p = _player;
if (p == null) return 0;
return p.state.duration.inMilliseconds / 1000.0;
}
Future<void> apply(List<PlayerAction> actions) async {
if (!_ready) return;
for (final action in actions) {
try {
switch (action) {
@@ -85,10 +230,33 @@ class PlaybackController {
}
}
/// Release the player. The view drops its texture first ([alive] goes
/// false, one frame passes), then mpv is torn down.
Future<void> dispose() async {
_ready = false;
final p = _player;
if (p == null) return;
_watchTimer?.cancel();
_watchTimer = null;
_player = null;
_video = null;
alive.value = false;
await _settleUi();
try {
await player.dispose();
await p.dispose();
} catch (_) {}
}
// Two frames: one may already be mid-build when [alive] flips. Then
// the length of a route pop (the fullscreen route holds its own `Video`
// and leaves animated). Bounded, since a hidden window renders no
// frames; headless tests have no binding and skip it all.
static Future<void> _settleUi() async {
try {
for (var i = 0; i < 2; i++) {
await WidgetsBinding.instance.endOfFrame
.timeout(const Duration(milliseconds: 300));
}
await Future.delayed(const Duration(milliseconds: 350));
} catch (_) {}
}