Swap shows only the new media, no guessed endings, quieter screen
- A swap stops the previous media at once; buffer counts only for the media the player holds (client) and the current mediaId (server), so the old video can no longer start the new one. - The automatic frozen-picture reload is gone (false alarms on still scenes); Reload video stays. - The "Next episode" pill shows only from a known ending. - A web link's thumbnail is its cover, under the video's own name. - While playing, the unread chip stays off the picture; chat pop-ups can be All / Only when paused / Off. - Server fetch failures say when the server has no cookies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab133dd82f
commit
27b4ec2c96
25 files changed
+274
-485
No files matched your search
@@ -81,16 +81,19 @@ corner; double-click or ⤢ makes it big again, × sits you out (below) and
|
|||||||
double-click on the picture goes fullscreen (the strip hides, the party
|
double-click on the picture goes fullscreen (the strip hides, the party
|
||||||
shows in a corner while paused, the window buttons sit in the player's top
|
shows in a corner while paused, the window buttons sit in the player's top
|
||||||
bar while its controls are up), Esc leaves it. The controls show the
|
bar while its controls are up), Esc leaves it. The controls show the
|
||||||
show's cover, title and episode top left (guests get the cover too); before
|
show's cover, title and episode top left (a web video: its thumbnail and
|
||||||
|
name; guests get the art too); before
|
||||||
a video first plays, its banner fills the picture until playback starts.
|
a video first plays, its banner fills the picture until playback starts.
|
||||||
One pill above the controls says what playback waits for ("Press Space
|
One pill above the controls says what playback waits for ("Press Space
|
||||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||||
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
Tab), ready to type; it shows messages, 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. Unread chat
|
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
||||||
shows as a count on the chat buttons and, in fullscreen with the controls
|
(Settings → Notifications → Chat messages: All, Only when paused or Off;
|
||||||
hidden, as a small chip top right.
|
messages always reach the chat). Unread chat shows as a count on the chat
|
||||||
|
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||||
|
as a small chip top right.
|
||||||
|
|
||||||
Your name and picture change for everyone in the server right away; the
|
Your name and picture change for everyone in the server right away; the
|
||||||
colour is yours only.
|
colour is yours only.
|
||||||
@@ -308,8 +311,9 @@ Watching:
|
|||||||
|
|
||||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||||
the ending (when it is known, see Skipping above; else in the last two
|
the ending, when it is known (see Skipping above), a "Next episode"
|
||||||
minutes) a "Next episode" button floats in the corner. When an
|
button floats in the corner; when it is not, nothing shows before the
|
||||||
|
end. When an
|
||||||
episode ends, a card shows what is next with Play next; with "Play the
|
episode ends, a card shows what is next with Play next; with "Play the
|
||||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
next episode automatically" (Settings, on by default) it counts down 10 s
|
||||||
first, and Cancel stops it on your screen (seeking back stops it for
|
first, and Cancel stops it on your screen (seeking back stops it for
|
||||||
@@ -379,7 +383,7 @@ fails to load shows a toast and switches shaders off.
|
|||||||
- "host offline - check address and port": wrong address, or the port is not open.
|
- "host offline - check address and port": wrong address, or the port is not open.
|
||||||
- "wrong server password": password mismatch.
|
- "wrong server password": password mismatch.
|
||||||
- "host runs a different version": update the app on both sides.
|
- "host runs a different version": update the app on both sides.
|
||||||
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically.
|
- Picture frozen but sound fine: Ctrl+R reloads the video.
|
||||||
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
|
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
|
||||||
- Windows: the build is new; if something is off there, say so.
|
- Windows: the build is new; if something is off there, say so.
|
||||||
|
|
||||||
|
|||||||
@@ -320,14 +320,13 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
|
|||||||
|
|
||||||
/// When the floating "Next episode" pill appears: at [endingStart] (the
|
/// When the floating "Next episode" pill appears: at [endingStart] (the
|
||||||
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
||||||
/// when one is known inside [runtime], else the last two minutes of
|
/// when one is known inside [runtime]. Null otherwise: no guessed ending,
|
||||||
/// [runtime] (all of it for shorter media).
|
/// the end-of-episode card at the real end is the only prompt then.
|
||||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
||||||
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||||
return endingStart;
|
return endingStart;
|
||||||
}
|
}
|
||||||
const lead = Duration(minutes: 2);
|
return null;
|
||||||
return runtime > lead ? runtime - lead : Duration.zero;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who performs the auto-advance swap, so several counting clients do not
|
/// Who performs the auto-advance swap, so several counting clients do not
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import '../shaders/shader_presets.dart';
|
|||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../sync/state_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
import '../sync/user_seek.dart';
|
import '../sync/user_seek.dart';
|
||||||
import 'frame_watchdog.dart';
|
|
||||||
import 'media_marks.dart';
|
import 'media_marks.dart';
|
||||||
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
|
||||||
|
|
||||||
@@ -41,9 +40,6 @@ class PlaybackController {
|
|||||||
|
|
||||||
SyncPlayer? _player;
|
SyncPlayer? _player;
|
||||||
VideoController? _video;
|
VideoController? _video;
|
||||||
Timer? _watchTimer;
|
|
||||||
bool _probing = false;
|
|
||||||
final FrameWatchdog _watchdog = FrameWatchdog();
|
|
||||||
StreamSubscription<PlayerLog>? _logSub;
|
StreamSubscription<PlayerLog>? _logSub;
|
||||||
final List<StreamSubscription<Object?>> _timeSubs = [];
|
final List<StreamSubscription<Object?>> _timeSubs = [];
|
||||||
String _chapterJson = '';
|
String _chapterJson = '';
|
||||||
@@ -60,9 +56,6 @@ class PlaybackController {
|
|||||||
/// otherwise, so no texture outlives its player.
|
/// otherwise, so no texture outlives its player.
|
||||||
final alive = signal(false);
|
final alive = signal(false);
|
||||||
|
|
||||||
/// Called once when the watchdog sees a frozen picture.
|
|
||||||
void Function()? onFrozen;
|
|
||||||
|
|
||||||
/// mpv's yt-dlp hook could not open the current link (its error text).
|
/// mpv's yt-dlp hook could not open the current link (its error text).
|
||||||
void Function(String error)? onYtdlError;
|
void Function(String error)? onYtdlError;
|
||||||
|
|
||||||
@@ -109,10 +102,6 @@ class PlaybackController {
|
|||||||
Player? _ytdlSetOn;
|
Player? _ytdlSetOn;
|
||||||
String _ytdlRaw = '';
|
String _ytdlRaw = '';
|
||||||
|
|
||||||
/// 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!;
|
Player get player => _player!;
|
||||||
|
|
||||||
/// Seeks from the video controls (seek bar) go here instead of to mpv.
|
/// Seeks from the video controls (seek bar) go here instead of to mpv.
|
||||||
@@ -217,8 +206,6 @@ class PlaybackController {
|
|||||||
await _pinBufferProps();
|
await _pinBufferProps();
|
||||||
await _pin('volume-max', '$maxVolume');
|
await _pin('volume-max', '$maxVolume');
|
||||||
await applyShaders();
|
await applyShaders();
|
||||||
_watchdog.reset();
|
|
||||||
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
|
||||||
generation.value++;
|
generation.value++;
|
||||||
alive.value = true;
|
alive.value = true;
|
||||||
}
|
}
|
||||||
@@ -439,73 +426,6 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens [url] paused. [headers] go with every request for it (mpv
|
/// Opens [url] paused. [headers] go with every request for it (mpv
|
||||||
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
|
||||||
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
|
Future<void> openUrl(String url, {Map<String, String>? headers}) async {
|
||||||
@@ -689,8 +609,6 @@ class PlaybackController {
|
|||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
final p = _player;
|
final p = _player;
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
_watchTimer?.cancel();
|
|
||||||
_watchTimer = null;
|
|
||||||
await _logSub?.cancel();
|
await _logSub?.cancel();
|
||||||
_logSub = null;
|
_logSub = null;
|
||||||
for (final sub in _timeSubs) {
|
for (final sub in _timeSubs) {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../files/name_parser.dart';
|
import '../files/name_parser.dart';
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
|
|
||||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||||
// art"): Jellyfin images with this install's account, else an AniList
|
// art"): Jellyfin images with this install's account, a web link's
|
||||||
// title search. Everything here fails quietly to "no art".
|
// thumbnail, else an AniList title search. Everything here fails quietly
|
||||||
|
// to "no art".
|
||||||
|
|
||||||
/// Poster and banner bytes; either may be null.
|
/// Poster and banner bytes; either may be null.
|
||||||
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
|
||||||
@@ -68,6 +71,31 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
|||||||
return (poster: poster, banner: banner);
|
return (poster: poster, banner: banner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A web video's thumbnail at [url] as both cover and banner (the same
|
||||||
|
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
||||||
|
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
||||||
|
/// larger; null when it cannot be had.
|
||||||
|
Future<ArtBytes?> thumbnailArt(Uri url,
|
||||||
|
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||||
|
var bytes = await fetch(url, const {}, 8 * 1024 * 1024);
|
||||||
|
if (bytes != null && bytes.length > RelayConstants.maxPosterBytes) bytes = await _shrinkApart(bytes);
|
||||||
|
return bytes == null ? null : (poster: bytes, banner: bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its own function, so the isolate's closure holds nothing but [raw].
|
||||||
|
Future<Uint8List?> _shrinkApart(Uint8List raw) => Isolate.run<Uint8List?>(() => _shrinkThumbnail(raw));
|
||||||
|
|
||||||
|
Uint8List? _shrinkThumbnail(Uint8List raw) {
|
||||||
|
final decoded = img.decodeImage(raw);
|
||||||
|
if (decoded == null) return null;
|
||||||
|
for (final width in const [1280, 960, 640]) {
|
||||||
|
final small = decoded.width > width ? img.copyResize(decoded, width: width, interpolation: img.Interpolation.average) : decoded;
|
||||||
|
final jpg = img.encodeJpg(small, quality: 82);
|
||||||
|
if (jpg.length <= RelayConstants.maxPosterBytes) return jpg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
|
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
|
||||||
/// type (see `avatarImageType`); else null.
|
/// type (see `avatarImageType`); else null.
|
||||||
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
|
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class NowPlaying {
|
|||||||
final String bannerHash;
|
final String bannerHash;
|
||||||
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
|
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
|
||||||
|
|
||||||
|
/// The art is a web video's thumbnail (the same picture as cover and
|
||||||
|
/// banner): shown wide, under the video's own name.
|
||||||
|
bool get thumbnail => posterHash.isNotEmpty && posterHash == bannerHash;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
other is NowPlaying &&
|
other is NowPlaying &&
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
|||||||
final art = media['art'] is Map ? media['art'] as Map : const {};
|
final art = media['art'] is Map ? media['art'] as Map : const {};
|
||||||
final poster = art['poster'] is String ? art['poster'] as String : '';
|
final poster = art['poster'] is String ? art['poster'] as String : '';
|
||||||
final banner = art['banner'] is String ? art['banner'] as String : '';
|
final banner = art['banner'] is String ? art['banner'] as String : '';
|
||||||
|
// A web video's thumbnail: its name as is, no episode guessing.
|
||||||
|
if (poster.isNotEmpty && poster == banner) {
|
||||||
|
return NowPlaying(title: name, posterHash: poster, bannerHash: banner);
|
||||||
|
}
|
||||||
final ref = refOfStateMedia(media);
|
final ref = refOfStateMedia(media);
|
||||||
final (title, episode) = ref != null
|
final (title, episode) = ref != null
|
||||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ class SessionCoordinator {
|
|||||||
/// Neighbouring episodes this viewer can put up (own account only).
|
/// Neighbouring episodes this viewer can put up (own account only).
|
||||||
final previousEpisode = signal<EpisodeLink?>(null);
|
final previousEpisode = signal<EpisodeLink?>(null);
|
||||||
final nextEpisode = signal<EpisodeLink?>(null);
|
final nextEpisode = signal<EpisodeLink?>(null);
|
||||||
/// The floating "Next episode" pill near the end of an episode.
|
/// The floating "Next episode" pill from a known ending (Outro segment
|
||||||
|
/// or ED chapter, [nextPromptStart]) on.
|
||||||
final nextPromptVisible = signal(false);
|
final nextPromptVisible = signal(false);
|
||||||
/// End-of-episode countdown; null when none runs here.
|
/// End-of-episode countdown; null when none runs here.
|
||||||
final autoAdvance = signal<AutoAdvance?>(null);
|
final autoAdvance = signal<AutoAdvance?>(null);
|
||||||
@@ -269,13 +270,18 @@ class SessionCoordinator {
|
|||||||
int _epoch = 0;
|
int _epoch = 0;
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
int _mediaId = -1;
|
int _mediaId = -1;
|
||||||
|
// The mediaId whose media the player holds (or is opening); -1 for none.
|
||||||
|
// Unlike [_mediaId] it survives a lost connection, so a reconnect to the
|
||||||
|
// same media keeps the player, and a swap stops the previous media.
|
||||||
|
int _playerMediaId = -1;
|
||||||
String? _openUrl;
|
String? _openUrl;
|
||||||
String _wsHost = '';
|
String _wsHost = '';
|
||||||
int _wsPort = 0;
|
int _wsPort = 0;
|
||||||
_RelayOrigin? _pendingOrigin;
|
_RelayOrigin? _pendingOrigin;
|
||||||
// This client's last swap, until a state shows it landed (a newer
|
// This client's last swap, until a state shows it landed (a newer
|
||||||
// mediaId than [after] with this member as `swapBy`): its art goes up then.
|
// mediaId than [after] with this member as `swapBy`): its art goes up then.
|
||||||
({HelloMedia media, int after})? _pendingArt;
|
// [link]: the web link it came from (a relayed one has no `url`).
|
||||||
|
({HelloMedia media, int after, String? link})? _pendingArt;
|
||||||
AniListClient? _aniList;
|
AniListClient? _aniList;
|
||||||
// What this member serves for media [$1]; survives a reconnect (the
|
// What this member serves for media [$1]; survives a reconnect (the
|
||||||
// state then looks new again).
|
// state then looks new again).
|
||||||
@@ -341,7 +347,6 @@ class SessionCoordinator {
|
|||||||
Future<void> initPlayer() async {
|
Future<void> initPlayer() async {
|
||||||
if (_playerReady) return;
|
if (_playerReady) return;
|
||||||
try {
|
try {
|
||||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
|
||||||
playback.onYtdlError = _setPlayerError;
|
playback.onYtdlError = _setPlayerError;
|
||||||
playback.onLoadError = _setPlayerError;
|
playback.onLoadError = _setPlayerError;
|
||||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||||
@@ -386,12 +391,12 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
bool _reloadingVideo = false;
|
bool _reloadingVideo = false;
|
||||||
|
|
||||||
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
|
/// "Reload video" (button, Ctrl+R, status menu): fresh
|
||||||
/// player + video output, current media reopened. While it runs the
|
/// player + video output, current media reopened. While it runs the
|
||||||
/// player counts as not ready, so reports/seek detection pause and the
|
/// player counts as not ready, so reports/seek detection pause and the
|
||||||
/// next broadcast resyncs position and play/pause (diff reset + play
|
/// next broadcast resyncs position and play/pause (diff reset + play
|
||||||
/// edge). The ready flag is never touched.
|
/// edge). The ready flag is never touched.
|
||||||
Future<void> reloadVideo({bool auto = false}) async {
|
Future<void> reloadVideo() async {
|
||||||
// Also allowed after a failed reload (player gone, session still on).
|
// Also allowed after a failed reload (player gone, session still on).
|
||||||
if (_params == null || _reloadingVideo) return;
|
if (_params == null || _reloadingVideo) return;
|
||||||
_reloadingVideo = true;
|
_reloadingVideo = true;
|
||||||
@@ -411,16 +416,6 @@ class SessionCoordinator {
|
|||||||
_playerReady = playback.alive.peek();
|
_playerReady = playback.alive.peek();
|
||||||
_reloadingVideo = false;
|
_reloadingVideo = false;
|
||||||
}
|
}
|
||||||
if (auto) {
|
|
||||||
_toastId++;
|
|
||||||
toasts.value = [
|
|
||||||
...toasts.peek(),
|
|
||||||
ToastItem(
|
|
||||||
id: _toastId,
|
|
||||||
text: 'Video froze — reloaded',
|
|
||||||
at: DateTime.now().toUtc())
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void markInput() => _idle.activity(DateTime.now().toUtc());
|
void markInput() => _idle.activity(DateTime.now().toUtc());
|
||||||
@@ -436,6 +431,7 @@ class SessionCoordinator {
|
|||||||
_wsPort = params.port;
|
_wsPort = params.port;
|
||||||
diff.reset();
|
diff.reset();
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
|
_playerMediaId = -1;
|
||||||
_openUrl = null;
|
_openUrl = null;
|
||||||
playerError.value = '';
|
playerError.value = '';
|
||||||
_memberErrors = const {};
|
_memberErrors = const {};
|
||||||
@@ -713,6 +709,7 @@ class SessionCoordinator {
|
|||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
_leaveJellyfinItem();
|
_leaveJellyfinItem();
|
||||||
|
await _leavePreviousMedia(r.mediaId);
|
||||||
final name = (r.media?['name'] as String?) ?? '';
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
if (ref != null) {
|
if (ref != null) {
|
||||||
await _playJellyfin(ref, r,
|
await _playJellyfin(ref, r,
|
||||||
@@ -837,6 +834,7 @@ class SessionCoordinator {
|
|||||||
starving.value = null;
|
starving.value = null;
|
||||||
if (_mediaId == -1 && _openUrl == null) return;
|
if (_mediaId == -1 && _openUrl == null) return;
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
|
_playerMediaId = -1;
|
||||||
_openUrl = null;
|
_openUrl = null;
|
||||||
_openHeaders = null;
|
_openHeaders = null;
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
@@ -849,6 +847,22 @@ class SessionCoordinator {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new media [mediaId]: the previous one leaves the player at once, so
|
||||||
|
/// nobody sees or hears it while the new one resolves, is fetched by the
|
||||||
|
/// server or opens. A reconnect to the media the player holds keeps it.
|
||||||
|
Future<void> _leavePreviousMedia(int mediaId) async {
|
||||||
|
final held = _playerMediaId;
|
||||||
|
_playerMediaId = mediaId;
|
||||||
|
if (held == -1 || held == mediaId) return;
|
||||||
|
_openUrl = null;
|
||||||
|
_openHeaders = null;
|
||||||
|
diff.reset();
|
||||||
|
if (!_playerReady) return;
|
||||||
|
try {
|
||||||
|
await playback.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||||
if (r.transport == 'playing') {
|
if (r.transport == 'playing') {
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
@@ -959,11 +973,15 @@ class SessionCoordinator {
|
|||||||
// Duration only once the open media is the server's current one.
|
// Duration only once the open media is the server's current one.
|
||||||
// Sitting out: still a report (it keeps this member fresh), empty.
|
// Sitting out: still a report (it keeps this member fresh), empty.
|
||||||
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
final known = !_lastOut && !_opening && _openUrl != null && _mediaId == r.mediaId;
|
||||||
|
// The buffer counts only while the player holds the server's current
|
||||||
|
// media: before a swap's media is open it is the previous one's (or
|
||||||
|
// none), and the start must wait for this member.
|
||||||
|
final holds = !_lastOut && !_opening && _openUrl != null && _playerMediaId == r.mediaId;
|
||||||
_checkPlayerError(r.mediaId);
|
_checkPlayerError(r.mediaId);
|
||||||
final error = playerError.peek();
|
final error = playerError.peek();
|
||||||
sync.sendReport(
|
sync.sendReport(
|
||||||
position: _lastOut ? 0 : playback.positionSecs,
|
position: _lastOut ? 0 : playback.positionSecs,
|
||||||
buffered: _lastOut ? 0 : playback.bufferedSecsAhead,
|
buffered: holds ? playback.bufferedSecsAhead : 0,
|
||||||
eventId: r.eventId,
|
eventId: r.eventId,
|
||||||
duration: known ? playback.durationSecs : 0,
|
duration: known ? playback.durationSecs : 0,
|
||||||
mediaId: known ? r.mediaId : 0,
|
mediaId: known ? r.mediaId : 0,
|
||||||
@@ -1240,11 +1258,11 @@ class SessionCoordinator {
|
|||||||
sync.sendNudge(memberId);
|
sync.sendNudge(memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _swap(HelloMedia media) {
|
void _swap(HelloMedia media, {String? link}) {
|
||||||
// Putting something up means watching it: back first.
|
// Putting something up means watching it: back first.
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
if (self != null && self.out) sync.sendOut(self.id, false);
|
if (self != null && self.out) sync.sendOut(self.id, false);
|
||||||
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url);
|
||||||
sync.sendSwap(media);
|
sync.sendSwap(media);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,24 +1271,24 @@ class SessionCoordinator {
|
|||||||
if (pending == null || r.mediaId <= pending.after) return;
|
if (pending == null || r.mediaId <= pending.after) return;
|
||||||
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
||||||
_pendingArt = null;
|
_pendingArt = null;
|
||||||
unawaited(_sendArt(r.mediaId, pending.media));
|
unawaited(_sendArt(r.mediaId, pending.media, pending.link));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the art of [media] (this client's swap, now [mediaId]) and
|
/// Fetches the art of [media] (this client's swap, now [mediaId], from
|
||||||
/// sends it; quietly nothing on any failure.
|
/// web [link] if any) and sends it; quietly nothing on any failure.
|
||||||
Future<void> _sendArt(int mediaId, HelloMedia media) async {
|
Future<void> _sendArt(int mediaId, HelloMedia media, String? link) async {
|
||||||
try {
|
try {
|
||||||
final ref = media.jellyfin;
|
final ref = media.jellyfin;
|
||||||
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
|
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
|
||||||
final ArtBytes? bytes;
|
final ArtBytes? bytes;
|
||||||
if (ref != null && client != null) {
|
if (ref != null && client != null) {
|
||||||
bytes = await jellyfinArt(client, ref.itemId);
|
bytes = await jellyfinArt(client, ref.itemId);
|
||||||
} else if (ref == null && (media.url ?? '').isNotEmpty) {
|
} else if (ref == null && (link ?? '').isNotEmpty) {
|
||||||
// A web link is not a show: never guess. A page's thumbnail is
|
// A web link is not a show: never guess. A page's thumbnail is
|
||||||
// its banner.
|
// its cover and banner both.
|
||||||
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
|
final thumb = (await _probeFor(link!))?.info?.thumbnail;
|
||||||
final uri = thumb == null ? null : Uri.tryParse(thumb);
|
final uri = thumb == null ? null : Uri.tryParse(thumb);
|
||||||
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
|
bytes = uri == null ? null : await thumbnailArt(uri);
|
||||||
} else {
|
} else {
|
||||||
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||||
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||||
@@ -1347,7 +1365,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||||
_swap(HelloMedia(
|
_swap(HelloMedia(
|
||||||
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)));
|
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// The server reads it with its own yt-dlp; only a page this app knows
|
// The server reads it with its own yt-dlp; only a page this app knows
|
||||||
@@ -1672,7 +1690,7 @@ class SessionCoordinator {
|
|||||||
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
|
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
|
||||||
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
|
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
|
||||||
endingStart: playback.ending.peek()?.start);
|
endingStart: playback.ending.peek()?.start);
|
||||||
show = snap.position * 1000 >= at.inMilliseconds;
|
show = at != null && snap.position * 1000 >= at.inMilliseconds;
|
||||||
}
|
}
|
||||||
nextPromptVisible.value = show;
|
nextPromptVisible.value = show;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,18 +47,20 @@ Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
||||||
/// film icon until (or unless) its art arrives.
|
/// film icon until (or unless) its art arrives; [wide] (a video
|
||||||
|
/// thumbnail): 16:9 at the same height.
|
||||||
class ArtCover extends StatelessWidget {
|
class ArtCover extends StatelessWidget {
|
||||||
final String hash;
|
final String hash;
|
||||||
final double width;
|
final double width;
|
||||||
const ArtCover({super.key, required this.hash, this.width = 40});
|
final bool wide;
|
||||||
|
const ArtCover({super.key, required this.hash, this.width = 40, this.wide = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final image = artImage(hash);
|
final image = artImage(hash);
|
||||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||||
return Container(
|
return Container(
|
||||||
width: width,
|
width: wide ? width * 1.5 * 16 / 9 : width,
|
||||||
height: width * 1.5,
|
height: width * 1.5,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: r,
|
borderRadius: r,
|
||||||
@@ -116,7 +118,7 @@ class NowPlayingBlock extends StatelessWidget {
|
|||||||
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
|
if (cover) ...[ArtCover(hash: n.posterHash, wide: n.thumbnail), const SizedBox(width: 12)],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import '../shaders/shader_presets.dart';
|
|||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../widgets/app_keys.dart';
|
import '../widgets/app_keys.dart';
|
||||||
import '../widgets/shown_frame_probe.dart';
|
|
||||||
import '../widgets/text_focus.dart';
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
@@ -93,7 +92,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
// `Video` (it never swaps controllers in place).
|
// `Video` (it never swaps controllers in place).
|
||||||
var _videoGen = -1;
|
var _videoGen = -1;
|
||||||
var _videoKey = GlobalKey<VideoState>();
|
var _videoKey = GlobalKey<VideoState>();
|
||||||
final _frameProbe = ShownFrameProbe();
|
|
||||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
DateTime? _lastKeyAt;
|
DateTime? _lastKeyAt;
|
||||||
late final void Function() _stopShaderWatch;
|
late final void Function() _stopShaderWatch;
|
||||||
@@ -114,7 +112,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_coord.jellyfin.ensureLoaded();
|
_coord.jellyfin.ensureLoaded();
|
||||||
_coord.playback.shownFrame = _frameProbe.sample;
|
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||||
AppKeys.addChatToggle(_toggleChat);
|
AppKeys.addChatToggle(_toggleChat);
|
||||||
@@ -132,8 +129,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
AppKeys.removeChatToggle(_toggleChat);
|
AppKeys.removeChatToggle(_toggleChat);
|
||||||
final playback = _coord.playback;
|
|
||||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
|
||||||
_stopShaderWatch();
|
_stopShaderWatch();
|
||||||
_noFocus.dispose();
|
_noFocus.dispose();
|
||||||
_controlsShown.dispose();
|
_controlsShown.dispose();
|
||||||
@@ -287,11 +282,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// The frozen-picture watchdog captures this boundary: the
|
video,
|
||||||
// video (and its controls), not the window around it.
|
|
||||||
RepaintBoundary(
|
|
||||||
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
|
||||||
),
|
|
||||||
if (_full) ...[
|
if (_full) ...[
|
||||||
_status(),
|
_status(),
|
||||||
_progressLine(),
|
_progressLine(),
|
||||||
@@ -755,15 +746,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Fullscreen hides the strip and its chat badge: while there is unread
|
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||||
/// chat and the controls are down, a small glass chip says so top right.
|
/// chat, the controls are down and the video is not playing, a small
|
||||||
|
/// glass chip says so top right (while it plays, only the controls' chat
|
||||||
|
/// button shows the count: nothing sits on the picture).
|
||||||
Widget _unreadChip() => Positioned(
|
Widget _unreadChip() => Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
top: 16,
|
top: 16,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: _controlsShown,
|
valueListenable: _controlsShown,
|
||||||
builder: (context, shown, _) {
|
builder: (context, shown, _) => SignalBuilder(builder: (context) {
|
||||||
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
final playing = _coord.serverState.value?.transport == 'playing';
|
||||||
|
final on = widget.unread > 0 && !widget.chatOpen && !shown && !playing;
|
||||||
return AnimatedOpacity(
|
return AnimatedOpacity(
|
||||||
duration: easeTime,
|
duration: easeTime,
|
||||||
curve: ease,
|
curve: ease,
|
||||||
@@ -783,7 +777,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -723,8 +723,8 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What pops up and what makes a sound. No switches: there is nothing to
|
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
||||||
/// choose yet.
|
/// a sound.
|
||||||
class NotificationsInfo extends StatelessWidget {
|
class NotificationsInfo extends StatelessWidget {
|
||||||
const NotificationsInfo({super.key});
|
const NotificationsInfo({super.key});
|
||||||
|
|
||||||
@@ -746,11 +746,39 @@ class NotificationsInfo extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
line('Joins, leaves, video changes and chat',
|
line('Joins, leaves and video changes',
|
||||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Chat messages'),
|
||||||
|
Text('Pop up the same way; always in Chat & activity (Tab)', style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => DropdownButton<ChatPopups>(
|
||||||
|
key: const ValueKey('chat-popups'),
|
||||||
|
value: AppSettings.instance.chatPopups.value,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) AppSettings.instance.setChatPopups(v);
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
for (final m in ChatPopups.values) DropdownMenuItem(value: m, child: Text(m.label)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||||
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
line('App notices', 'Shader changes, the idle warning'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class StartCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (!small) ...[
|
// A thumbnail already fills the card as the banner.
|
||||||
|
if (!small && !now.thumbnail) ...[
|
||||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
|
|
||||||
import '../session/session_coordinator.dart' show ToastItem;
|
import '../session/session_coordinator.dart' show ToastItem;
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../state/settings.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|||||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||||
/// through.
|
/// through. Chat toasts follow [AppSettings.chatPopups].
|
||||||
class PartyToasts extends StatefulWidget {
|
class PartyToasts extends StatefulWidget {
|
||||||
final Duration life;
|
final Duration life;
|
||||||
final VoidCallback? onOpenChat;
|
final VoidCallback? onOpenChat;
|
||||||
@@ -76,8 +77,13 @@ class _PartyToastsState extends State<PartyToasts> {
|
|||||||
if (fresh.isEmpty) return;
|
if (fresh.isEmpty) return;
|
||||||
// Only new toasts re-run this, not the party they mention.
|
// Only new toasts re-run this, not the party they mention.
|
||||||
untracked(() {
|
untracked(() {
|
||||||
|
final playing = coord.serverState.value?.transport == 'playing';
|
||||||
|
final popups = AppSettings.instance.chatPopups.value;
|
||||||
for (final t in fresh) {
|
for (final t in fresh) {
|
||||||
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
|
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
|
||||||
|
// Chat per Settings → Notifications (it is in the chat either way).
|
||||||
|
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||||
|
_add(toast);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,27 @@ const _keyWelcomeDismissed = 'welcomeDismissed';
|
|||||||
const _keySoundPresetFor = 'soundPresetFor';
|
const _keySoundPresetFor = 'soundPresetFor';
|
||||||
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
const _keyUnreadyOnSwap = 'unreadyOnSwap';
|
||||||
const _keyIdleSitsOut = 'idleSitsOut';
|
const _keyIdleSitsOut = 'idleSitsOut';
|
||||||
|
const _keyChatPopups = 'chatPopups';
|
||||||
|
|
||||||
enum HwdecMode { auto, off, custom }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
/// When chat messages pop up as toasts (they always reach the chat).
|
||||||
|
enum ChatPopups {
|
||||||
|
all('All'),
|
||||||
|
paused('Only when paused'),
|
||||||
|
off('Off');
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
const ChatPopups(this.label);
|
||||||
|
|
||||||
|
/// Whether a chat message pops up now ([playing]: the video plays).
|
||||||
|
bool shows({required bool playing}) => switch (this) {
|
||||||
|
ChatPopups.all => true,
|
||||||
|
ChatPopups.paused => !playing,
|
||||||
|
ChatPopups.off => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
static final AppSettings instance = AppSettings();
|
static final AppSettings instance = AppSettings();
|
||||||
|
|
||||||
@@ -52,6 +70,8 @@ class AppSettings {
|
|||||||
/// A 2 px progress line at the bottom of the video while the controls
|
/// A 2 px progress line at the bottom of the video while the controls
|
||||||
/// are hidden (both designs). Applies live.
|
/// are hidden (both designs). Applies live.
|
||||||
final progressLine = signal(true);
|
final progressLine = signal(true);
|
||||||
|
/// Chat message toasts. Applies live.
|
||||||
|
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,6 +129,8 @@ class AppSettings {
|
|||||||
if (hideRe is bool) hideReReleases.value = hideRe;
|
if (hideRe is bool) hideReReleases.value = hideRe;
|
||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
if (line is bool) progressLine.value = line;
|
if (line is bool) progressLine.value = line;
|
||||||
|
final popups = box.get(_keyChatPopups);
|
||||||
|
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);
|
||||||
@@ -181,6 +203,11 @@ class AppSettings {
|
|||||||
_put(_keyProgressLine, on);
|
_put(_keyProgressLine, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setChatPopups(ChatPopups mode) {
|
||||||
|
chatPopups.value = mode;
|
||||||
|
_put(_keyChatPopups, mode.name);
|
||||||
|
}
|
||||||
|
|
||||||
void setSoundVolume(double v) {
|
void setSoundVolume(double v) {
|
||||||
soundVolume.value = v.clamp(0, 1);
|
soundVolume.value = v.clamp(0, 1);
|
||||||
_put(_keySoundVolume, soundVolume.value);
|
_put(_keySoundVolume, soundVolume.value);
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import '../playback/frame_watchdog.dart';
|
|
||||||
|
|
||||||
/// Fingerprints what the compositor really shows of the video. Place
|
|
||||||
/// [anchor] under a `RepaintBoundary` around the video (the session player
|
|
||||||
/// does); [sample] captures that boundary around the newest mounted anchor
|
|
||||||
/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own
|
|
||||||
/// counters keep ticking when the Flutter texture is stuck.
|
|
||||||
class ShownFrameProbe {
|
|
||||||
final _anchors = <_AnchorState>[];
|
|
||||||
|
|
||||||
Widget anchor() => _Anchor(probe: this);
|
|
||||||
|
|
||||||
Future<int?> sample() async {
|
|
||||||
for (final a in _anchors.reversed) {
|
|
||||||
if (!a.mounted) continue;
|
|
||||||
final boundary = a.context.findAncestorRenderObjectOfType<RenderRepaintBoundary>();
|
|
||||||
if (boundary == null || !boundary.attached || !boundary.hasSize) return null;
|
|
||||||
final width = boundary.size.width;
|
|
||||||
if (width <= 0) return null;
|
|
||||||
ui.Image? image;
|
|
||||||
try {
|
|
||||||
image = await boundary.toImage(pixelRatio: (200 / width).clamp(0.02, 1.0));
|
|
||||||
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
|
||||||
if (data == null) return null;
|
|
||||||
return hashCenterPixels(data, image.width, image.height);
|
|
||||||
} catch (_) {
|
|
||||||
// Route not painted (covered) or mid-teardown: no reading.
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
image?.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Anchor extends StatefulWidget {
|
|
||||||
final ShownFrameProbe probe;
|
|
||||||
const _Anchor({required this.probe});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_Anchor> createState() => _AnchorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AnchorState extends State<_Anchor> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
widget.probe._anchors.add(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.probe._anchors.remove(this);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:watch_together/src/playback/frame_watchdog.dart';
|
|
||||||
|
|
||||||
final _t0 = DateTime.utc(2026, 1, 1);
|
|
||||||
|
|
||||||
/// Feeds one sample per second; [frames]/[pos] give the value at second i.
|
|
||||||
List<bool> _run(
|
|
||||||
FrameWatchdog w,
|
|
||||||
int seconds, {
|
|
||||||
required int? Function(int i) frames,
|
|
||||||
double Function(int i)? pos,
|
|
||||||
bool Function(int i)? eligible,
|
|
||||||
int start = 0,
|
|
||||||
}) {
|
|
||||||
return [
|
|
||||||
for (var i = start; i < start + seconds; i++)
|
|
||||||
w.feed(FrameSample(
|
|
||||||
at: _t0.add(Duration(seconds: i)),
|
|
||||||
position: pos?.call(i) ?? i.toDouble(),
|
|
||||||
frames: frames(i),
|
|
||||||
eligible: eligible?.call(i) ?? true,
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
test('hashes tell frames apart and ignore the edges', () {
|
|
||||||
final a = ByteData(100 * 60 * 4);
|
|
||||||
final b = ByteData(100 * 60 * 4);
|
|
||||||
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
|
||||||
b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area
|
|
||||||
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
|
||||||
b.setUint8((30 * 100 + 50) * 4, 255); // centre: video
|
|
||||||
expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60)));
|
|
||||||
final raw = Uint8List(1 << 20);
|
|
||||||
final h = hashSpread(raw);
|
|
||||||
raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1;
|
|
||||||
expect(hashSpread(raw), isNot(h));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('frames advancing never fires', () {
|
|
||||||
final w = FrameWatchdog();
|
|
||||||
expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('frames stuck while position advances fires once after ~3 s', () {
|
|
||||||
final w = FrameWatchdog();
|
|
||||||
final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150);
|
|
||||||
expect(fired.where((f) => f).length, 1);
|
|
||||||
expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pause, buffering, missing data and seeks do not fire', () {
|
|
||||||
final w = FrameWatchdog();
|
|
||||||
// Paused / buffering: not eligible.
|
|
||||||
expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false),
|
|
||||||
everyElement(isFalse));
|
|
||||||
// No video output (headless / audio-only).
|
|
||||||
expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse));
|
|
||||||
// Position stuck too (network stall): not a frozen picture.
|
|
||||||
expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20),
|
|
||||||
everyElement(isFalse));
|
|
||||||
// Seeking every 2 s with frames stuck in between: jumps reset it.
|
|
||||||
expect(
|
|
||||||
_run(w, 12,
|
|
||||||
frames: (_) => 100,
|
|
||||||
pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9,
|
|
||||||
start: 30),
|
|
||||||
everyElement(isFalse));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no reload loop: re-arms only after 10 s of healthy frames', () {
|
|
||||||
final w = FrameWatchdog();
|
|
||||||
expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1);
|
|
||||||
w.reset(); // new player
|
|
||||||
// Brief healthy spell, then stuck again: must not fire.
|
|
||||||
expect(
|
|
||||||
_run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10)
|
|
||||||
.where((f) => f)
|
|
||||||
.length,
|
|
||||||
0);
|
|
||||||
// Healthy for 10 s+, then stuck: fires again.
|
|
||||||
w.reset();
|
|
||||||
expect(
|
|
||||||
_run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30)
|
|
||||||
.where((f) => f)
|
|
||||||
.length,
|
|
||||||
1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('still scene: dismiss re-arms after another full stall', () {
|
|
||||||
final w = FrameWatchdog();
|
|
||||||
final fired = <int>[];
|
|
||||||
for (var i = 0; i < 20; i++) {
|
|
||||||
if (w.feed(FrameSample(
|
|
||||||
at: _t0.add(Duration(seconds: i)),
|
|
||||||
position: i.toDouble(),
|
|
||||||
frames: 5,
|
|
||||||
eligible: true))) {
|
|
||||||
fired.add(i);
|
|
||||||
w.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(fired, [3, 6, 9, 12, 15, 18]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -301,9 +301,9 @@ void main() {
|
|||||||
a.toggleReady();
|
a.toggleReady();
|
||||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
||||||
|
|
||||||
// Account holders see the "Next episode" prompt (this item is
|
// No known ending (no Outro segment, no ED chapter): no floating
|
||||||
// shorter than its 2 min lead); the guest cannot put it up.
|
// "Next episode" prompt mid-episode, only the card at the end.
|
||||||
await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
|
expect(a.nextPromptVisible.peek(), isFalse);
|
||||||
expect(b.nextPromptVisible.peek(), isFalse);
|
expect(b.nextPromptVisible.peek(), isFalse);
|
||||||
|
|
||||||
// Alice and the guest pick English; it carries to the next episode.
|
// Alice and the guest pick English; it carries to the next episode.
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ void main() {
|
|||||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the floating prompt starts two minutes before the end', () {
|
test('no floating prompt without a known ending', () {
|
||||||
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
|
expect(nextPromptStart(null, const Duration(minutes: 24)), isNull);
|
||||||
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
|
expect(nextPromptStart(null, const Duration(seconds: 90)), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||||
|
|||||||
@@ -154,14 +154,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('next episode prompt', () {
|
group('next episode prompt', () {
|
||||||
test('at the ED start when known, else the last two minutes', () {
|
test('at the ED start when known, else none (never a guessed time)', () {
|
||||||
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
|
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
|
||||||
expect(nextPromptStart(null, m(24)), m(22));
|
expect(nextPromptStart(null, m(24)), isNull);
|
||||||
expect(nextPromptStart(null, m(24), endingStart: null), m(22));
|
expect(nextPromptStart(null, m(24), endingStart: null), isNull);
|
||||||
// Nonsense ending starts fall back.
|
// Nonsense ending starts count as unknown.
|
||||||
expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22));
|
expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull);
|
||||||
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22));
|
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull);
|
||||||
expect(nextPromptStart(null, s(90)), Duration.zero);
|
expect(nextPromptStart(null, s(90)), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -374,19 +374,19 @@ void main() {
|
|||||||
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
|
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
|
||||||
expect(app.who, isNull);
|
expect(app.who, isNull);
|
||||||
expect(app.text, 'Bob joined');
|
expect(app.text, 'Bob joined');
|
||||||
final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who);
|
final notice = shellToastFor(ToastItem(id: 4, text: 'Shaders off', at: at), who);
|
||||||
expect(froze.who, isNull);
|
expect(notice.who, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
|
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
|
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
|
||||||
coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())];
|
coord.toasts.value = [ToastItem(id: 1, text: 'Shaders off', at: DateTime.now())];
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Video froze — reloaded'), findsOneWidget);
|
expect(find.text('Shaders off'), findsOneWidget);
|
||||||
await tester.pump(const Duration(seconds: 1));
|
await tester.pump(const Duration(seconds: 1));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Video froze — reloaded'), findsNothing);
|
expect(find.text('Shaders off'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('status avatar fills its box at any size', (tester) async {
|
testWidgets('status avatar fills its box at any size', (tester) async {
|
||||||
|
|||||||
+26
-21
@@ -98,7 +98,11 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
the media `duration` and its `mediaId` once the player knows it. The
|
the media `duration` and its `mediaId` once the player knows it. The
|
||||||
server stores `buffered` and liveness, and takes the first reported
|
server stores `buffered` and liveness, and takes the first reported
|
||||||
duration for the current media (swap resets it); a report with `eventId`
|
duration for the current media (swap resets it); a report with `eventId`
|
||||||
older than current has its position ignored.
|
older than current has its position ignored. `buffered` is the current
|
||||||
|
media's only: the client sends 0 until the player holds it (a swap's
|
||||||
|
media still resolving, being fetched by the server or opening), and the
|
||||||
|
server counts a report naming an earlier `mediaId` as 0, so the old
|
||||||
|
media's buffer never passes the gate for the new one.
|
||||||
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
|
- **Ready gate** (HELD to PLAYING): the in-set is non-empty and every
|
||||||
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
|
in-set member is ready, fresh and has `buffered >= need`. `need` is 4 s
|
||||||
(`minBufferSecs`), or near the end what is left minus 2 s
|
(`minBufferSecs`), or near the end what is left minus 2 s
|
||||||
@@ -336,17 +340,10 @@ session: created on join, disposed on leave, replaced by `recreate()` for
|
|||||||
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
|
`StateDiff` actions, the play gate (`localPlayAllowed`: self in-set and
|
||||||
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
ready) or the user's own seeks (see Sync model). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||||
stops the player (otherwise audio keeps playing). On media change
|
stops the player (otherwise audio keeps playing). On media change
|
||||||
(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0,
|
(`mediaId` bump) the coordinator stops the previous media at once (no old
|
||||||
holds and resets rate.
|
picture or sound while the new one resolves, is fetched or opens; a
|
||||||
|
reconnect to the media the player holds keeps it), then opens the new URL
|
||||||
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
|
and `StateDiff` seeks to 0, holds and resets rate.
|
||||||
small capture of the compositor's centre (`ShownFrameProbe`) and suspects a
|
|
||||||
freeze when position advances while that hash stays constant for 3 s. Two
|
|
||||||
differing mpv `screenshot-raw` frames show the decoder is still producing
|
|
||||||
pictures, which confirms the freeze (a still scene is dismissed). A confirmed
|
|
||||||
freeze reloads the video with a toast. It fires once until the picture moves
|
|
||||||
again for 10 s, so it cannot loop. Seeks, pause, buffering and a hidden window
|
|
||||||
disarm it.
|
|
||||||
|
|
||||||
Chapters, segments, tracks (`PlaybackController` signals, values from
|
Chapters, segments, tracks (`PlaybackController` signals, values from
|
||||||
`playback/media_marks.dart`, no widgets; any controls UI reads them):
|
`playback/media_marks.dart`, no widgets; any controls UI reads them):
|
||||||
@@ -673,7 +670,7 @@ all three link modes. Nothing is bundled or installed by hand.
|
|||||||
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
|
(`AppYtDlp.probe`, remembered 10 minutes; media file names too, not a
|
||||||
Jellyfin item link) for the
|
Jellyfin item link) for the
|
||||||
Link preview and the swap: a page is named by its title, never its URL
|
Link preview and the swap: a page is named by its title, never its URL
|
||||||
tail, and its thumbnail goes up as the banner (no poster). A link with a
|
tail, and its thumbnail goes up as its art (see "Media art"). A link with a
|
||||||
problem, or one the probe cannot reach ("Couldn't reach the site"), is
|
problem, or one the probe cannot reach ("Couldn't reach the site"), is
|
||||||
not put up in any mode: the Link page shows the reason (preview and
|
not put up in any mode: the Link page shows the reason (preview and
|
||||||
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
under the button) and offers "Put it up anyway" (`LinkMedia.anyway`),
|
||||||
@@ -747,9 +744,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
|||||||
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
||||||
previous and next episode. The controls show previous / next buttons
|
previous and next episode. The controls show previous / next buttons
|
||||||
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's
|
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's
|
||||||
start when one is known, see "Skip rules", else the last 2 min of the
|
start, see "Skip rules") a floating "Next episode" pill shows outside
|
||||||
runtime) a floating "Next episode" pill shows outside the auto-hiding
|
the auto-hiding controls. Without a known ending there is no pill (never
|
||||||
controls. Auto-advance still waits for `finished`. Any
|
a guessed time); the end card at `finished` is the only prompt. Auto-advance still waits for `finished`. Any
|
||||||
of them swaps to that episode's ref with the clicker's account. Guests
|
of them swaps to that episode's ref with the clicker's account. Guests
|
||||||
(relay, blocked) see none of it.
|
(relay, blocked) see none of it.
|
||||||
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
||||||
@@ -978,12 +975,16 @@ always scrolls the page). Space never presses a focused button
|
|||||||
feed lines between them. Opening focuses the field; Tab or Esc in it
|
feed lines between them. Opening focuses the field; Tab or Esc in it
|
||||||
close the panel and focus goes back where it was. Unread chat badges
|
close the panel and focus goes back where it was. Unread chat badges
|
||||||
the strip's and both player designs' chat buttons; in fullscreen with
|
the strip's and both player designs' chat buttons; in fullscreen with
|
||||||
the controls down a glass chip (`PlayerGlass`) top right shows it.
|
the controls down and the video not playing a glass chip
|
||||||
|
(`PlayerGlass`) top right shows it (while it plays nothing sits on the
|
||||||
|
picture).
|
||||||
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
|
||||||
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
the avatar of their `ToastItem.actor` (feed and chat toasts; the app's
|
||||||
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.
|
a chat toast opens the chat; the others let clicks through. Chat toasts
|
||||||
|
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only
|
||||||
|
when paused, Off); the message is in the chat either way.
|
||||||
|
|
||||||
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
|
||||||
@@ -1187,7 +1188,11 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
|||||||
- Media art the same way (`/i/...`, `ArtCache` as `coordinator.art`): the
|
- Media art the same way (`/i/...`, `ArtCache` as `coordinator.art`): the
|
||||||
member who swapped the media in fetches poster and banner once the state
|
member who swapped the media in fetches poster and banner once the state
|
||||||
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
||||||
its own account, else an AniList title search on the parsed release
|
its own account; a web link's yt-dlp thumbnail, the one the Library's
|
||||||
name) and sends `art`, so guests without Jellyfin get covers too.
|
Recent grid shows, as poster and banner both, shrunk to a JPEG under the
|
||||||
|
poster cap when larger; else an AniList title search on the parsed
|
||||||
|
release name) and sends `art`, so guests without Jellyfin get covers too.
|
||||||
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||||
`state.media` (`now_playing_info.dart`).
|
`state.media` (`now_playing_info.dart`); poster and banner of one hash
|
||||||
|
mark a thumbnail (`NowPlaying.thumbnail`): the media name as is, no
|
||||||
|
episode line, a 16:9 cover in the controls and none on the start card.
|
||||||
+7
-2
@@ -68,6 +68,9 @@ Field notes:
|
|||||||
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
|
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
|
||||||
media's length in seconds and the `mediaId` it belongs to. The server takes
|
media's length in seconds and the `mediaId` it belongs to. The server takes
|
||||||
the first one whose `mediaId` is current while its media duration is 0.
|
the first one whose `mediaId` is current while its media duration is 0.
|
||||||
|
`report.buffered` is the current media's: a client sends 0 while its
|
||||||
|
player does not hold it yet, and the server counts a report whose
|
||||||
|
`mediaId` names an earlier media as `buffered` 0.
|
||||||
- `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this
|
- `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this
|
||||||
client plays the item with its own account, `false` = through the relay.
|
client plays the item with its own account, `false` = through the relay.
|
||||||
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
||||||
@@ -202,7 +205,8 @@ state media.art = {"poster":"<hash>","banner":"<hash>"}
|
|||||||
decoded bytes) counts as none. A later `art` replaces the earlier one
|
decoded bytes) counts as none. A later `art` replaces the earlier one
|
||||||
whole; any swap clears it.
|
whole; any swap clears it.
|
||||||
- `media.art` carries hashes like `avatarHash`; a key is omitted when that
|
- `media.art` carries hashes like `avatarHash`; a key is omitted when that
|
||||||
picture is absent, `art` when both are.
|
picture is absent, `art` when both are. The same picture as poster and
|
||||||
|
banner (one hash) is a web video's thumbnail.
|
||||||
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
|
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
|
||||||
current media has no such art.
|
current media has no such art.
|
||||||
|
|
||||||
@@ -273,7 +277,8 @@ anything else means one file.
|
|||||||
server password`: an open server would fetch any address it can reach
|
server password`: an open server would fetch any address it can reach
|
||||||
for anyone). A web page `url` is read through the server's yt-dlp; when
|
for anyone). A web page `url` is read through the server's yt-dlp; when
|
||||||
the server cannot read the link, the swapper later gets `The server
|
the server cannot read the link, the swapper later gets `The server
|
||||||
can't fetch this link: <reason>`. Any later swap ends the relay.
|
can't fetch this link: <reason>`; when yt-dlp failed and the server has
|
||||||
|
no cookies, the reason is `it has no cookies (...)` instead. Any later swap ends the relay.
|
||||||
`tracks` on a member's swap names the tracks its source will serve; the
|
`tracks` on a member's swap names the tracks its source will serve; the
|
||||||
server ignores it with `fetch` and with `jellyfin`.
|
server ignores it with `fetch` and with `jellyfin`.
|
||||||
- `state.media.relay.source`: member id of the source (absent with
|
- `state.media.relay.source`: member id of the source (absent with
|
||||||
|
|||||||
@@ -429,7 +429,11 @@ class SyncEngine {
|
|||||||
_setError(member, mediaId == this.mediaId ? error : '');
|
_setError(member, mediaId == this.mediaId ? error : '');
|
||||||
_seen(member);
|
_seen(member);
|
||||||
if (jf != null) member.jf = Map.unmodifiable(jf);
|
if (jf != null) member.jf = Map.unmodifiable(jf);
|
||||||
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
// A report for an earlier media (sent before the swap reached the
|
||||||
|
// client) says nothing about this one's buffer.
|
||||||
|
final earlier = mediaId != 0 && mediaId != this.mediaId;
|
||||||
|
member.buffered =
|
||||||
|
member.out || earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||||
final m = media;
|
final m = media;
|
||||||
// First report of the length wins; only for the media the client has open.
|
// First report of the length wins; only for the media the client has open.
|
||||||
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
||||||
|
|||||||
@@ -429,6 +429,24 @@ void main() {
|
|||||||
expect(engine.members['b']!.inSet, isTrue);
|
expect(engine.members['b']!.inSet, isTrue);
|
||||||
expect(engine.transport, Transport.held);
|
expect(engine.transport, Transport.held);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the previous media's buffer never starts the new one", () {
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
engine.applyReady('b', true);
|
||||||
|
engine.applySwap('a', ep2);
|
||||||
|
final oldEvent = engine.eventId - 1;
|
||||||
|
// Sent before the swap reached the clients, landing after it.
|
||||||
|
engine.applyReport('a', 30, 20.0, oldEvent, mediaId: 1);
|
||||||
|
engine.applyReport('b', 30, 20.0, oldEvent, mediaId: 1);
|
||||||
|
expect(engine.members['a']!.buffered, 0);
|
||||||
|
engine.tick();
|
||||||
|
expect(engine.transport, Transport.held);
|
||||||
|
engine.applyReport('a', 0, 6.0, engine.eventId, mediaId: 2);
|
||||||
|
engine.applyReport('b', 0, 6.0, engine.eventId, mediaId: 2);
|
||||||
|
expect(engine.transport, Transport.playing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('force with actor feed', () {
|
group('force with actor feed', () {
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ class ServerHub {
|
|||||||
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
|
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the swapper reads when the server cannot fetch a link ([why]
|
||||||
|
/// from the relay). A yt-dlp failure on a server without cookies says
|
||||||
|
/// so, unless cookies would not help (unknown site, gone, unreachable).
|
||||||
|
String fetchFailText(String why) {
|
||||||
|
const head = "The server can't fetch this link";
|
||||||
|
final y = ytDlp;
|
||||||
|
final plain = playErrorText(why);
|
||||||
|
final cookiesMightHelp = y != null &&
|
||||||
|
y.enabled &&
|
||||||
|
why.contains('yt-dlp') &&
|
||||||
|
!const {unsupportedUrlText, notFoundText, unreachableText}.contains(plain) &&
|
||||||
|
y.cookies.read(count: false) == null;
|
||||||
|
return cookiesMightHelp
|
||||||
|
? '$head: it has no cookies (set them in Settings → Player → yt-dlp on the hosting app, or --yt-dlp-cookies)'
|
||||||
|
: '$head: $why';
|
||||||
|
}
|
||||||
|
|
||||||
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
||||||
|
|
||||||
/// The relay of the current media (`/r/...`), if it is relayed.
|
/// The relay of the current media (`/r/...`), if it is relayed.
|
||||||
@@ -362,7 +379,7 @@ void mountSyncRoute(
|
|||||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||||
ytDlp: hub.ytDlp,
|
ytDlp: hub.ytDlp,
|
||||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||||
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
|
onFail: (why) => wsErr(ws, hub.fetchFailText(why)))
|
||||||
: _memberRelay(hub, token, name, media.relay!.tracks);
|
: _memberRelay(hub, token, name, media.relay!.tracks);
|
||||||
hub.setRelay(active);
|
hub.setRelay(active);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
|
|||||||
Reference in new issue
Block a user