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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user