diff --git a/README.md b/README.md index c9e85b7..ebac1c6 100644 --- a/README.md +++ b/README.md @@ -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 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 -(Settings → Notifications → Chat messages: All, Only when paused or Off; -messages always reach the chat). Unread chat shows as a count on the chat +(Settings → Notifications → Chat messages: Only when paused by default, +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, 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 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 -(also "Skip 1:30") shows for 5 seconds when it comes up, then only while -the controls are up (move the mouse). The opening and +of the ending only; what comes after is yours to skip). "Skip OP" and +"Skip 1:30" show for 5 seconds when they come up, then only while the +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 them (needs a segment plugin, e.g. Intro Skipper), else from chapter names (OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or @@ -311,9 +312,9 @@ Watching: Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`, 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" -button floats in the corner; when it is not, nothing shows before the -end. When an +the ending (when it is known, see Skipping above; else in the last two +minutes) a "Next episode" button floats in the corner while the controls +are up. When an 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 first, and Cancel stops it on your screen (seeking back stops it for diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index 84e4f44..dec4a86 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -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`) -/// when one is known inside [runtime]. Null otherwise: no guessed ending, -/// the end-of-episode card at the real end is the only prompt then. -Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) { +/// when one is known inside [runtime], else the last two minutes of +/// [runtime] (all of it for shorter media). The pill shows only while the +/// 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) { 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 diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index 5bdc512..6565c17 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -36,9 +36,8 @@ class EpisodeSkipButton extends StatelessWidget { } } -/// Floating "Next episode ›" from the ED (or the last two minutes): -/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so -/// it shows in fullscreen and with the controls hidden. +/// Floating "Next episode ›" from the ED (or the last two minutes), while +/// the controls are up (`PillGate`): faint at rest, lit on hover. class NextEpisodePill extends StatelessWidget { final EpisodeLink episode; final VoidCallback onPressed; diff --git a/apps/desktop/lib/src/screens/session/player_controls.dart b/apps/desktop/lib/src/screens/session/player_controls.dart index 4a84ba0..2afe829 100644 --- a/apps/desktop/lib/src/screens/session/player_controls.dart +++ b/apps/desktop/lib/src/screens/session/player_controls.dart @@ -526,8 +526,8 @@ class SkipForwardButton extends StatelessWidget { } /// 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 -/// it shows in fullscreen and with the controls hidden. +/// faint at rest, lit on hover. Lives outside the auto-hiding controls +/// (also in fullscreen); `PillGate` shows it with them. class FloatingPill extends StatefulWidget { final String label; final String tooltip; diff --git a/apps/desktop/lib/src/session/now_playing.dart b/apps/desktop/lib/src/session/now_playing.dart index cd63dca..b9c2d3c 100644 --- a/apps/desktop/lib/src/session/now_playing.dart +++ b/apps/desktop/lib/src/session/now_playing.dart @@ -9,11 +9,11 @@ class NowPlaying { /// Portrait cover (2:3) and wide banner; '' when none. final String posterHash; final String 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; + /// 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. + final bool thumbnail; + const NowPlaying( + {required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false}); @override bool operator ==(Object other) => @@ -21,8 +21,9 @@ class NowPlaying { other.title == title && other.episode == episode && other.posterHash == posterHash && - other.bannerHash == bannerHash; + other.bannerHash == bannerHash && + other.thumbnail == thumbnail; @override - int get hashCode => Object.hash(title, episode, posterHash, bannerHash); + int get hashCode => Object.hash(title, episode, posterHash, bannerHash, thumbnail); } diff --git a/apps/desktop/lib/src/session/now_playing_info.dart b/apps/desktop/lib/src/session/now_playing_info.dart index 454ac14..acad9e3 100644 --- a/apps/desktop/lib/src/session/now_playing_info.dart +++ b/apps/desktop/lib/src/session/now_playing_info.dart @@ -1,3 +1,5 @@ +import 'package:sync_relay_core/core.dart' show hasMediaExtension; + import '../files/name_parser.dart'; import '../jellyfin/session_media.dart'; import 'now_playing.dart'; @@ -16,17 +18,38 @@ NowPlaying? nowPlayingOf(Map? media) { final art = media['art'] is Map ? media['art'] as Map : const {}; final poster = art['poster'] is String ? art['poster'] 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); + // 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 ? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle) : fileTitle(name); 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 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 /// "Series - S01E02") for episodes, the year for movies. Episodes give the /// series and "S1 E6 · Name"; anything else the name alone. diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 355abb6..df2fc3a 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -200,8 +200,9 @@ class SessionCoordinator { /// Neighbouring episodes this viewer can put up (own account only). final previousEpisode = signal(null); final nextEpisode = signal(null); - /// The floating "Next episode" pill from a known ending (Outro segment - /// or ED chapter, [nextPromptStart]) on. + /// The "Next episode" pill's window ([nextPromptStart]: the known ending, + /// else the last two minutes); the pill shows in it only while the + /// controls are up. final nextPromptVisible = signal(false); /// End-of-episode countdown; null when none runs here. final autoAdvance = signal(null); @@ -1690,7 +1691,7 @@ class SessionCoordinator { final item = (jellyfinPlan.peek() as OwnPlay?)?.item; final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()), endingStart: playback.ending.peek()?.start); - show = at != null && snap.position * 1000 >= at.inMilliseconds; + show = snap.position * 1000 >= at.inMilliseconds; } nextPromptVisible.value = show; } diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index adc48c0..9af1d59 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -668,8 +668,8 @@ class _SessionPlayerState extends State { /// Jellyfin: the blocked card, the end-of-episode card (countdown) and /// the "Next episode" pill; for any media the skip pill (Skip OP / ED / - /// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so - /// they stay up. + /// 1:30) above it. Outside the auto-hiding controls; the cards stay up, + /// the pills show only with the controls ([PillGate]). Widget _jellyfinOverlay() { final coord = _coord; return Positioned.fill( @@ -712,13 +712,21 @@ class _SessionPlayerState extends State { spacing: 10, children: [ if (skip != null) - SkipPillGate( + PillGate( 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, 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 { } } -/// The skip pill's visibility: up for [firstShown] after an offer -/// appears (a new kind: OP, ED, 1:30), then only while the controls are -/// up or the mouse is on it; gone with the offer (the parent drops it). -class SkipPillGate extends StatefulWidget { +/// A floating pill's visibility: only while the controls are up (the +/// mouse moved) or the mouse is on it. With [announce] it is also up for +/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30). +/// Gone with the offer (the parent drops it). +class PillGate extends StatefulWidget { static const firstShown = Duration(seconds: 5); - final SkipOffer offer; + final Object kind; + final bool announce; final ValueListenable controlsShown; 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 - State createState() => _SkipPillGateState(); + State createState() => _PillGateState(); } -class _SkipPillGateState extends State { - SkipKind? _kind; +class _PillGateState extends State { + Object? _kind; bool _fresh = false; bool _hover = false; Timer? _timer; @@ -927,9 +943,9 @@ class _SkipPillGateState extends State { } @override - void didUpdateWidget(SkipPillGate old) { + void didUpdateWidget(PillGate old) { super.didUpdateWidget(old); - if (widget.offer.kind != _kind) _arm(); + if (widget.kind != _kind) _arm(); } @override @@ -939,10 +955,11 @@ class _SkipPillGateState extends State { } void _arm() { - _kind = widget.offer.kind; - _fresh = true; + _kind = widget.kind; _timer?.cancel(); - _timer = Timer(SkipPillGate.firstShown, () { + _fresh = widget.announce; + if (!_fresh) return; + _timer = Timer(PillGate.firstShown, () { if (mounted) setState(() => _fresh = false); }); } diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index f1ea5e8..1f08114 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -70,8 +70,8 @@ class AppSettings { /// A 2 px progress line at the bottom of the video while the controls /// are hidden (both designs). Applies live. final progressLine = signal(true); - /// Chat message toasts. Applies live. - final chatPopups = signal(ChatPopups.all); + /// Chat message toasts (default: only when paused). Applies live. + final chatPopups = signal(ChatPopups.paused); /// App sounds (`sfx/`): 0..1, 0 = silent. final soundVolume = signal(1.0); /// `SfxKind` names switched off one by one. @@ -130,7 +130,7 @@ class AppSettings { final line = box.get(_keyProgressLine); if (line is bool) progressLine.value = line; 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); if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); final off = box.get(_keySoundsOff); diff --git a/apps/desktop/test/jellyfin_e2e_test.dart b/apps/desktop/test/jellyfin_e2e_test.dart index f75ac25..3471390 100644 --- a/apps/desktop/test/jellyfin_e2e_test.dart +++ b/apps/desktop/test/jellyfin_e2e_test.dart @@ -301,9 +301,9 @@ void main() { a.toggleReady(); await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again'); - // No known ending (no Outro segment, no ED chapter): no floating - // "Next episode" prompt mid-episode, only the card at the end. - expect(a.nextPromptVisible.peek(), isFalse); + // Account holders see the "Next episode" prompt (this item is + // shorter than its 2 min lead); the guest cannot put it up. + await _poll(() => a.nextPromptVisible.peek(), 'next prompt'); expect(b.nextPromptVisible.peek(), isFalse); // Alice and the guest pick English; it carries to the next episode. diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index a9bd33a..e857a07 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -321,9 +321,9 @@ void main() { expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7'); }); - test('no floating prompt without a known ending', () { - expect(nextPromptStart(null, const Duration(minutes: 24)), isNull); - expect(nextPromptStart(null, const Duration(seconds: 90)), isNull); + test('the floating prompt starts two minutes before the end', () { + expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22)); + expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero); }); test('one auto-advance swapper: the lowest id among members with their own account', () { diff --git a/apps/desktop/test/media_marks_test.dart b/apps/desktop/test/media_marks_test.dart index 17259a1..9a47769 100644 --- a/apps/desktop/test/media_marks_test.dart +++ b/apps/desktop/test/media_marks_test.dart @@ -154,14 +154,14 @@ void main() { }); 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)), isNull); - expect(nextPromptStart(null, m(24), endingStart: null), isNull); - // Nonsense ending starts count as unknown. - expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull); - expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull); - expect(nextPromptStart(null, s(90)), isNull); + expect(nextPromptStart(null, m(24)), m(22)); + expect(nextPromptStart(null, m(24), endingStart: null), m(22)); + // Nonsense ending starts fall back. + expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22)); + expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22)); + expect(nextPromptStart(null, s(90)), Duration.zero); }); }); diff --git a/apps/desktop/test/now_playing_info_test.dart b/apps/desktop/test/now_playing_info_test.dart index ab40117..5a214d6 100644 --- a/apps/desktop/test/now_playing_info_test.dart +++ b/apps/desktop/test/now_playing_info_test.dart @@ -42,4 +42,25 @@ void main() { 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')); }); + + 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')); + }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 00ba273..db6c6af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -744,9 +744,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, `/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the previous and next episode. The controls show previous / next buttons (also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's - start, see "Skip rules") a floating "Next episode" pill shows outside - the auto-hiding controls. Without a known ending there is no pill (never - a guessed time); the end card at `finished` is the only prompt. Auto-advance still waits for `finished`. Any + start when one is known, see "Skip rules", else the last 2 min of the + runtime) a floating "Next episode" pill shows while the controls are up + (see "Session controls"). Auto-advance still waits for `finished`. Any of them swaps to that episode's ref with the clicker's account. Guests (relay, blocked) see none of it. - **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 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. + when paused (default), Off); the message is in the chat either way. Settings persist in two Hive boxes: `connect_form` (last form, recent 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 private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / 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`) -shows for 5 s when an offer of a new kind appears, then only while the -controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown` -for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both +cards stay up; a pill (`PillGate`) shows only while the controls are up +(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or +the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of +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` (150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part 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 release name) and sends `art`, so guests without Jellyfin get covers too. `coordinator.nowPlaying` derives title, episode line and art hashes from - `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. + `state.media` (`now_playing_info.dart`). A web video + (`NowPlaying.thumbnail`) shows the media name as is, no episode line, a + 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.