Pills only with the controls, web links in their final layout at once, chat pop-ups when paused by default

- Next episode (known ending, else the last two minutes) and Skip ED
  show only while the controls are up, like Skip 1:30 (PillGate).
- A web link's name and wide cover slot show from the first state, so
  nothing flips when its thumbnail arrives.
- Chat pop-ups default to Only when paused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 05:13:50 +03:00
1 parent 27b4ec2c96
commit 2a7b553597
14 files changed
+149 -78

No files matched your search

+9 -8
View File
@@ -90,8 +90,8 @@ loading…") or what went wrong, with Retry or Cancel when it helps. Chat and ac
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 other news also pop up bottom left with the person's avatar; click a message to open the chat
(Settings → Notifications → Chat messages: All, Only when paused or Off; (Settings → Notifications → Chat messages: Only when paused by default,
messages always reach the chat). Unread chat shows as a count on the chat or All, or Off; 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, buttons and, in fullscreen with the controls hidden and the video paused,
as a small chip top right. as a small chip top right.
@@ -248,9 +248,10 @@ the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it Skipping: during the opening a "Skip OP" button floats in the corner (it
jumps to the end of the opening), during the ending "Skip ED" (to the end jumps to the end of the opening), during the ending "Skip ED" (to the end
of the ending only; what comes after is yours to skip). Any skip button of the ending only; what comes after is yours to skip). "Skip OP" and
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while "Skip 1:30" show for 5 seconds when they come up, then only while the
the controls are up (move the mouse). The opening and controls are up (move the mouse); "Skip ED" and "Next episode" show only
while the controls are up. The opening and
ending come from Jellyfin's intro / outro segments when your Jellyfin has ending come from Jellyfin's intro / outro segments when your Jellyfin has
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
@@ -311,9 +312,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), a "Next episode" the ending (when it is known, see Skipping above; else in the last two
button floats in the corner; when it is not, nothing shows before the minutes) a "Next episode" button floats in the corner while the controls
end. When an are up. 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
@@ -318,15 +318,17 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
} }
} }
/// When the floating "Next episode" pill appears: at [endingStart] (the /// Where the "Next episode" pill's window starts: 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]. Null otherwise: no guessed ending, /// when one is known inside [runtime], else the last two minutes of
/// the end-of-episode card at the real end is the only prompt then. /// [runtime] (all of it for shorter media). The pill shows only while the
Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { /// controls are up (`PillGate`), so a guessed window costs nothing.
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;
} }
return null; const lead = Duration(minutes: 2);
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
@@ -36,9 +36,8 @@ class EpisodeSkipButton extends StatelessWidget {
} }
} }
/// Floating "Next episode ›" from the ED (or the last two minutes): /// Floating "Next episode ›" from the ED (or the last two minutes), while
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so /// the controls are up (`PillGate`): faint at rest, lit on hover.
/// it shows in fullscreen and with the controls hidden.
class NextEpisodePill extends StatelessWidget { class NextEpisodePill extends StatelessWidget {
final EpisodeLink episode; final EpisodeLink episode;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -526,8 +526,8 @@ class SkipForwardButton extends StatelessWidget {
} }
/// Floating pill over the video (Next episode, Skip OP / ED / 1:30): /// Floating pill over the video (Next episode, Skip OP / ED / 1:30):
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so /// faint at rest, lit on hover. Lives outside the auto-hiding controls
/// it shows in fullscreen and with the controls hidden. /// (also in fullscreen); `PillGate` shows it with them.
class FloatingPill extends StatefulWidget { class FloatingPill extends StatefulWidget {
final String label; final String label;
final String tooltip; final String tooltip;
@@ -9,11 +9,11 @@ class NowPlaying {
/// Portrait cover (2:3) and wide banner; '' when none. /// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash; final String posterHash;
final String bannerHash; final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''}); /// A web video: its art is its thumbnail (the same picture as cover and
/// banner, or none yet), shown wide, under the video's own name.
/// The art is a web video's thumbnail (the same picture as cover and final bool thumbnail;
/// banner): shown wide, under the video's own name. const NowPlaying(
bool get thumbnail => posterHash.isNotEmpty && posterHash == bannerHash; {required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false});
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -21,8 +21,9 @@ class NowPlaying {
other.title == title && other.title == title &&
other.episode == episode && other.episode == episode &&
other.posterHash == posterHash && other.posterHash == posterHash &&
other.bannerHash == bannerHash; other.bannerHash == bannerHash &&
other.thumbnail == thumbnail;
@override @override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash); int get hashCode => Object.hash(title, episode, posterHash, bannerHash, thumbnail);
} }
@@ -1,3 +1,5 @@
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
import '../files/name_parser.dart'; import '../files/name_parser.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import 'now_playing.dart'; import 'now_playing.dart';
@@ -16,17 +18,38 @@ 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);
// A web video: its name as is, no episode guessing, its thumbnail as
// the art. Told by the art once it is up (the same picture as cover and
// banner), before that by the media itself, so it never changes layout.
final web = poster.isNotEmpty || banner.isNotEmpty ? poster == banner : ref == null && isWebVideo(media);
if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
}
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)
: fileTitle(name); : fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner); return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
} }
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
/// will be its thumbnail: a shared http(s) link that is no media file (the
/// everyone-opens-it mode), or a relayed one (passed on by a member or
/// fetched by the server: the URL stays private) named by the page's title,
/// not a file name. A relayed file keeps its file name's extension.
bool isWebVideo(Map<String, Object?> media) {
final url = media['url'];
if (url is String && url.isNotEmpty) {
final uri = Uri.tryParse(url);
return uri != null &&
(uri.isScheme('http') || uri.isScheme('https')) &&
uri.host.isNotEmpty &&
!hasMediaExtension(uri);
}
final name = media['name'] is String ? media['name'] as String : '';
return media['relay'] is Map && name.isNotEmpty && !_ext.hasMatch(name);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or /// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the /// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone. /// series and "S1 E6 · Name"; anything else the name alone.
@@ -200,8 +200,9 @@ 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 from a known ending (Outro segment /// The "Next episode" pill's window ([nextPromptStart]: the known ending,
/// or ED chapter, [nextPromptStart]) on. /// else the last two minutes); the pill shows in it only while the
/// controls are up.
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);
@@ -1690,7 +1691,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 = at != null && snap.position * 1000 >= at.inMilliseconds; show = snap.position * 1000 >= at.inMilliseconds;
} }
nextPromptVisible.value = show; nextPromptVisible.value = show;
} }
+36 -19
View File
@@ -668,8 +668,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED / /// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so /// 1:30) above it. Outside the auto-hiding controls; the cards stay up,
/// they stay up. /// the pills show only with the controls ([PillGate]).
Widget _jellyfinOverlay() { Widget _jellyfinOverlay() {
final coord = _coord; final coord = _coord;
return Positioned.fill( return Positioned.fill(
@@ -712,13 +712,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
spacing: 10, spacing: 10,
children: [ children: [
if (skip != null) if (skip != null)
SkipPillGate( PillGate(
key: const ValueKey('skip-gate'), key: const ValueKey('skip-gate'),
offer: skip, kind: skip.kind,
// Skip ED: no announcing, only with the controls.
announce: skip.kind != SkipKind.ending,
controlsShown: _controlsShown, controlsShown: _controlsShown,
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)), child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
), ),
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode), if (showNext)
PillGate(
key: const ValueKey('next-gate'),
kind: next,
controlsShown: _controlsShown,
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
),
], ],
), ),
), ),
@@ -900,22 +908,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
} }
/// The skip pill's visibility: up for [firstShown] after an offer /// A floating pill's visibility: only while the controls are up (the
/// appears (a new kind: OP, ED, 1:30), then only while the controls are /// mouse moved) or the mouse is on it. With [announce] it is also up for
/// up or the mouse is on it; gone with the offer (the parent drops it). /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
class SkipPillGate extends StatefulWidget { /// Gone with the offer (the parent drops it).
class PillGate extends StatefulWidget {
static const firstShown = Duration(seconds: 5); static const firstShown = Duration(seconds: 5);
final SkipOffer offer; final Object kind;
final bool announce;
final ValueListenable<bool> controlsShown; final ValueListenable<bool> controlsShown;
final Widget child; final Widget child;
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child}); const PillGate({
super.key,
required this.kind,
this.announce = false,
required this.controlsShown,
required this.child,
});
@override @override
State<SkipPillGate> createState() => _SkipPillGateState(); State<PillGate> createState() => _PillGateState();
} }
class _SkipPillGateState extends State<SkipPillGate> { class _PillGateState extends State<PillGate> {
SkipKind? _kind; Object? _kind;
bool _fresh = false; bool _fresh = false;
bool _hover = false; bool _hover = false;
Timer? _timer; Timer? _timer;
@@ -927,9 +943,9 @@ class _SkipPillGateState extends State<SkipPillGate> {
} }
@override @override
void didUpdateWidget(SkipPillGate old) { void didUpdateWidget(PillGate old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
if (widget.offer.kind != _kind) _arm(); if (widget.kind != _kind) _arm();
} }
@override @override
@@ -939,10 +955,11 @@ class _SkipPillGateState extends State<SkipPillGate> {
} }
void _arm() { void _arm() {
_kind = widget.offer.kind; _kind = widget.kind;
_fresh = true;
_timer?.cancel(); _timer?.cancel();
_timer = Timer(SkipPillGate.firstShown, () { _fresh = widget.announce;
if (!_fresh) return;
_timer = Timer(PillGate.firstShown, () {
if (mounted) setState(() => _fresh = false); if (mounted) setState(() => _fresh = false);
}); });
} }
+3 -3
View File
@@ -70,8 +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. /// Chat message toasts (default: only when paused). Applies live.
final chatPopups = signal(ChatPopups.all); final chatPopups = signal(ChatPopups.paused);
/// 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.
@@ -130,7 +130,7 @@ class AppSettings {
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); final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all; chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
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);
+3 -3
View File
@@ -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');
// No known ending (no Outro segment, no ED chapter): no floating // Account holders see the "Next episode" prompt (this item is
// "Next episode" prompt mid-episode, only the card at the end. // shorter than its 2 min lead); the guest cannot put it up.
expect(a.nextPromptVisible.peek(), isFalse); await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
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.
+3 -3
View File
@@ -321,9 +321,9 @@ void main() {
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
}); });
test('no floating prompt without a known ending', () { test('the floating prompt starts two minutes before the end', () {
expect(nextPromptStart(null, const Duration(minutes: 24)), isNull); expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
expect(nextPromptStart(null, const Duration(seconds: 90)), isNull); expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
}); });
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', () {
+7 -7
View File
@@ -154,14 +154,14 @@ void main() {
}); });
group('next episode prompt', () { group('next episode prompt', () {
test('at the ED start when known, else none (never a guessed time)', () { test('at the ED start when known, else the last two minutes', () {
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)), isNull); expect(nextPromptStart(null, m(24)), m(22));
expect(nextPromptStart(null, m(24), endingStart: null), isNull); expect(nextPromptStart(null, m(24), endingStart: null), m(22));
// Nonsense ending starts count as unknown. // Nonsense ending starts fall back.
expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull); expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22));
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull); expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22));
expect(nextPromptStart(null, s(90)), isNull); expect(nextPromptStart(null, s(90)), Duration.zero);
}); });
}); });
@@ -42,4 +42,25 @@ void main() {
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb')); const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12')); expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
}); });
test('web videos: name as is and wide from the start, before the thumbnail', () {
const title = 'Frieren - 05 | reaction';
// Everyone opens it, passed on by a member, fetched by the server.
for (final media in [
{'name': title, 'url': 'https://www.youtube.com/watch?v=x'},
{'name': title, 'relay': {'source': 'm2', 'live': true}},
{'name': title, 'relay': {'fetch': true}},
]) {
expect(nowPlayingOf(media), const NowPlaying(title: title, thumbnail: true));
expect(nowPlayingOf({...media, 'art': {'poster': 'aa', 'banner': 'aa'}}),
const NowPlaying(title: title, posterHash: 'aa', bannerHash: 'aa', thumbnail: true));
}
// Files (a link to one, a relayed one) and Jellyfin keep their parsing.
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'url': 'https://cdn.example/Show%20-%2012.mkv'}),
const NowPlaying(title: 'Show', episode: 'E12'));
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'relay': {'source': 'm2'}}),
const NowPlaying(title: 'Show', episode: 'E12'));
expect(nowPlayingOf({'name': 'Show - 12', 'relay': {}, 'art': {'poster': 'p', 'banner': 'b'}}),
const NowPlaying(title: 'Show', episode: 'E12', posterHash: 'p', bannerHash: 'b'));
});
} }
+17 -11
View File
@@ -744,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, see "Skip rules") a floating "Next episode" pill shows outside start when one is known, see "Skip rules", else the last 2 min of the
the auto-hiding controls. Without a known ending there is no pill (never runtime) a floating "Next episode" pill shows while the controls are up
a guessed time); the end card at `finished` is the only prompt. Auto-advance still waits for `finished`. Any (see "Session controls"). 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
@@ -984,7 +984,7 @@ always scrolls the page). Space never presses a focused button
slides in, stays 5 s, then slides out and collapses its gap. A click on slides in, stays 5 s, then slides out and collapses its gap. A click on
a chat toast opens the chat; the others let clicks through. Chat toasts a chat toast opens the chat; the others let clicks through. Chat toasts
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only follow `AppSettings.chatPopups` (Settings → Notifications: All, Only
when paused, Off); the message is in the chat either way. when paused (default), 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
@@ -1056,10 +1056,11 @@ keeps its row at the top). A drag sends no hover events, so the bar keeps
the controls up through a guarded dynamic `onHover()` on media_kit's the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls. The 1:30, Next episode) and cards live outside the auto-hiding controls. The
Next episode pill and the cards stay up; the skip pill (`SkipPillGate`) cards stay up; a pill (`PillGate`) shows only while the controls are up
shows for 5 s when an offer of a new kind appears, then only while the (`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or
controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown` the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of
for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both a new kind appears (Skip ED and Next episode never without the controls).
Volume (`controls/volume.dart`, both
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume` designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part (150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
above in amber; the wheel and ↑ / ↓ step 5 %, clamped. above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
@@ -1193,6 +1194,11 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
poster cap when larger; else an AniList title search on the parsed poster cap when larger; else an AniList title search on the parsed
release name) and sends `art`, so guests without Jellyfin get covers too. 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`); poster and banner of one hash `state.media` (`now_playing_info.dart`). A web video
mark a thumbnail (`NowPlaying.thumbnail`): the media name as is, no (`NowPlaying.thumbnail`) shows the media name as is, no episode line, a
episode line, a 16:9 cover in the controls and none on the start card. 16:9 cover in the controls (a neutral placeholder until the art is up)
and none on the start card. Art up: poster and banner of one hash mark
it. Before the art: `isWebVideo` tells it from the media itself (an
http(s) `url` without a media file extension, or a relayed, non-Jellyfin
media whose name has no file extension: a page's title), so the layout
never switches when the thumbnail lands.