From d092ea371a49347d192240c5fc4ee2a181586609 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 00:58:12 +0300 Subject: [PATCH] 1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag Library: source switch, selectable cards with a details panel, one merged Continue row, Latest grouped per series without re-releases, Link page with previews and recent links, Files embedded. Player: gear popover in both designs, progress line while controls hide, live hwdec. Settings rebuilt as cards. State marks the host member (proto stays 9, unreleased); hosting shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps the outgoing view's state. Co-Authored-By: Claude Opus 5.5 --- README.md | 76 ++- .../lib/src/controls/controls_probe.dart | 58 ++ .../lib/src/controls/player_settings.dart | 407 ++++++++++++ .../lib/src/controls/uosc/controls.dart | 31 +- .../lib/src/controls/uosc/timeline.dart | 2 +- apps/desktop/lib/src/jellyfin/accounts.dart | 33 + .../lib/src/jellyfin/browse_scope.dart | 225 +++++-- apps/desktop/lib/src/jellyfin/browser.dart | 296 ++++----- apps/desktop/lib/src/jellyfin/cards.dart | 238 ++++--- .../desktop/lib/src/jellyfin/detail_page.dart | 180 ----- .../lib/src/jellyfin/details_panel.dart | 520 +++++++++++++++ apps/desktop/lib/src/jellyfin/format.dart | 3 + apps/desktop/lib/src/jellyfin/home_page.dart | 216 ++++-- apps/desktop/lib/src/jellyfin/home_rows.dart | 163 +++++ apps/desktop/lib/src/jellyfin/inspector.dart | 128 ---- .../lib/src/jellyfin/library_page.dart | 9 +- .../lib/src/jellyfin/picker_source.dart | 28 +- .../desktop/lib/src/jellyfin/series_page.dart | 228 ++++--- .../lib/src/screens/home/about_page.dart | 4 +- .../lib/src/screens/home/media_picker.dart | 341 +--------- .../lib/src/screens/home/settings_page.dart | 208 ++---- .../lib/src/screens/session/swap_panel.dart | 139 ---- .../lib/src/session/session_coordinator.dart | 7 +- apps/desktop/lib/src/shell/app_shell.dart | 1 + apps/desktop/lib/src/shell/avatar.dart | 5 +- apps/desktop/lib/src/shell/library_link.dart | 621 ++++++++++++++++++ apps/desktop/lib/src/shell/library_tab.dart | 375 ++++++++++- apps/desktop/lib/src/shell/motion.dart | 4 +- apps/desktop/lib/src/shell/party_strip.dart | 130 +++- .../desktop/lib/src/shell/session_player.dart | 87 ++- apps/desktop/lib/src/shell/settings_tab.dart | 291 +++++++- apps/desktop/lib/src/shell/toasts.dart | 49 +- apps/desktop/lib/src/state/settings.dart | 26 +- apps/desktop/test/coord_presence_test.dart | 28 + apps/desktop/test/home_test.dart | 36 +- apps/desktop/test/jellyfin_accounts_test.dart | 21 +- apps/desktop/test/jellyfin_browser_test.dart | 272 ++++---- apps/desktop/test/jellyfin_session_test.dart | 81 +-- apps/desktop/test/library_rows_test.dart | 242 +++++++ apps/desktop/test/library_tab_test.dart | 392 +++++++++++ apps/desktop/test/player_settings_test.dart | 321 +++++++++ apps/desktop/test/shell_test.dart | 12 +- apps/desktop/test/uosc_controls_test.dart | 16 +- docs/ARCHITECTURE.md | 136 ++-- docs/PROTOCOL.md | 6 +- packages/core/lib/src/jellyfin/client.dart | 19 +- packages/core/lib/src/jellyfin/models.dart | 5 + packages/core/lib/src/protocol.dart | 5 + packages/core/lib/src/sync_engine.dart | 3 + packages/core/test/jellyfin_client_test.dart | 22 + packages/core/test/sync_engine_test.dart | 15 + packages/server/lib/src/server_hub.dart | 4 +- packages/server/test/hardening_test.dart | 17 + packages/server/test/session_test.dart | 6 + 54 files changed, 4963 insertions(+), 1825 deletions(-) create mode 100644 apps/desktop/lib/src/controls/controls_probe.dart create mode 100644 apps/desktop/lib/src/controls/player_settings.dart delete mode 100644 apps/desktop/lib/src/jellyfin/detail_page.dart create mode 100644 apps/desktop/lib/src/jellyfin/details_panel.dart create mode 100644 apps/desktop/lib/src/jellyfin/home_rows.dart delete mode 100644 apps/desktop/lib/src/jellyfin/inspector.dart delete mode 100644 apps/desktop/lib/src/screens/session/swap_panel.dart create mode 100644 apps/desktop/lib/src/shell/library_link.dart create mode 100644 apps/desktop/test/library_rows_test.dart create mode 100644 apps/desktop/test/library_tab_test.dart create mode 100644 apps/desktop/test/player_settings_test.dart diff --git a/README.md b/README.md index d9f1068..e446157 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ profile: name, picture with a crop editor, colour, and links to the settings), the Library / Schedule / Settings tabs, and the party. Offline the party side has Join and Host; in a server it shows everyone's avatar with a ring for their state (green ready, amber buffering, grey not ready, -dimmed reconnecting; a crown on yours when you host, a tower on whoever +dimmed reconnecting; a crown on whoever hosts, a tower on whoever passes the video on). Click the avatars for the member list (force ready, -kick). Beside them the server and "ready n/m", the chat button with unread +kick, "Copy invite address"; when you host it lists this computer's +network addresses with the port, and friends outside your network need +that port forwarded or a server they can all reach, like a VPS). Beside them the server and "ready n/m", the chat button with unread count, and Leave. Empty strip space drags the window. The player fills the window under the strip. Pick a tab and it shrinks to @@ -53,7 +55,8 @@ a mini player in a corner: drag it anywhere and it snaps to the nearest corner; double-click or ⤢ makes it big again, × stops watching (you stay in the server, muted) and the strip's screen button brings it back. F or double-click on the picture goes fullscreen (the strip hides, the party -shows in a corner while paused), Esc leaves it. While playback waits, a +shows in a corner while paused, the window buttons sit in the player's top +bar while its controls are up), Esc leaves it. While playback waits, a pill under the picture says for whom ("Waiting for you · press Space when ready"). Chat and activity open in a panel on the right (chat button or Tab); joins, leaves, chat and other news also pop up bottom left with the @@ -67,7 +70,7 @@ colour is yours only. Host in the strip: set a port and an optional password (blank = anyone can join), then Host. The app starts a server inside itself and joins it; you are the host and nobody can kick you. Nothing is up yet: pick what to -watch in the Library tab (below) and "Put it up for everyone". Leaving or +watch in the Library tab (below) and put it up. Leaving or closing the app stops the server. Friends need to reach your machine: open the TCP port on your router (or use a LAN or VPN address). Friends open nothing. Ports you hosted on before are one click away in the Host card. @@ -77,10 +80,30 @@ disk. Nothing is uploaded or stored anywhere. ## Library -What to watch: Jellyfin (browse your linked servers, see below), Link -(paste an http(s) URL; Ctrl+V outside a field pastes it in) or File (path -or Browse), then "Put it up for everyone" and the player opens. Offline the -library browses but nothing goes up until you join or host. +What to watch, from three sources (the switch at the top; Ctrl+V outside +a field pastes a link into Link): + +- **Jellyfin**: your linked servers (see below). Library chips beside the + switch, then one **Continue** row (what you were watching and what comes + next, one card per series, the one you watched most recently first; + series not touched for two months drop out) and **Latest in** each + library (one card per series: "S1E1" or "3 new"; Show all opens the + library newest first). Re-uploads (added long after they aired) are + hidden from Latest; the row's ⋮ menu turns that off. Search (/), library + grids (sort, unwatched, favourites) and series pages sit behind them. + Click a card to select it: its + details open on the right with "Put it up · S1E3", mark watched / + unwatched, remove from Continue and the episode list (click an episode + to choose it). Double-click a card or an episode to put it up at once. +- **Link**: paste an http(s) link. The preview shows what it is (title and + thumbnail for YouTube, else the file name); in a server, pick how the + others get it (everyone fetches it, the server fetches it, or through + you). Your last 12 links are listed under it. +- **Files**: video folders you add on this computer, grouped by series, + or any file through Browse. The others stream it from you. + +Put it up and the player opens. Offline the library browses but nothing +goes up until you join or host ("Join or host to watch together"). ## Dedicated server @@ -118,13 +141,23 @@ is one, then Enter or Join. Your name and picture come from your profile ## Settings -The Settings tab (Ctrl+,) has a page per topic: Profile; Player (hardware -decoding: auto, off or custom, the env var `SYNC_RELAY_HWDEC` overrides -it, applies on the next join or Reload video; "Experimental: mpv-style -controls", below; video shaders, see below); Jellyfin & integrations -(linked Jellyfin servers, below, and playing the next episode -automatically); Updates; Keyboard shortcuts (read-only); About (the -version). +The Settings tab (Ctrl+,) has a page per topic, each setting with a line +of help: Profile; Party & network (the Host card's default port and +password, recent servers with remove); Player (below); Jellyfin & +integrations (linked Jellyfin servers, below, the AnimeFlow connection: +"connected as " with what you may do, or why not, and playing the +next episode automatically); Notifications & sounds (what pops up; there +is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About +(the version). + +Player settings live in the player: ⚙ at the right end of its controls +(both designs) opens them, and Settings → Player has the same ones (plus +the custom shader files). Player design: Standard or uosc (below); +progress line when hidden (on by default: a 2 px line at the bottom of +the video while the controls are hidden); upscaling shader (see below); +hardware decoding (auto, off or custom; the env var `SYNC_RELAY_HWDEC` +overrides it); in the player also the subtitle and audio track. Every +change applies right away. Keys while the player fills the window: Space = ready / unready, Tab = chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, @@ -140,15 +173,15 @@ shows the time and the chapter title. The chapters button lists them Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3 2.0 · title"; subtitles have Off. -Experimental mpv-style controls (Settings → Player, off by default, +The uosc player design (⚙ → Player design, Standard by default, switches right away): video controls modeled on mpv's uosc theme. A thick timeline across the bottom shows the played part, the cached part ahead (hatched), chapter starts as diamonds and the time; hovering it shows the chapter title and the time there. With the controls hidden it stays as a -thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the +thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the track count), the shader badge (the active Anime4K mode, e.g. "A HQ"), -ready, chapters, 1:30, previous / next episode, change video, volume and -fullscreen; chat and back-to-library top right. Keys and skip buttons work +ready, chapters, 1:30, previous / next episode, change video, volume, ⚙ +and fullscreen; chat and back-to-library top right. Keys and skip buttons work the same. Skipping: during the opening a "Skip OP" button floats in the corner (it @@ -258,8 +291,9 @@ C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast Pick one with the ✨ button in the player's control bar (also in fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next -session starts with it. Settings → Player sets the default, -HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time +session starts with it. The player's ⚙ and Settings → Player set it too, +with HQ or Fast (used by Ctrl+1..6); Settings → Player holds the custom +files. Shaders cost GPU time every frame and need hardware video rendering: on media_kit's software fallback they do nothing, and the app says so. A shader that is missing or fails to load shows a toast and switches shaders off. diff --git a/apps/desktop/lib/src/controls/controls_probe.dart b/apps/desktop/lib/src/controls/controls_probe.dart new file mode 100644 index 0000000..57e951b --- /dev/null +++ b/apps/desktop/lib/src/controls/controls_probe.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Follows whether media_kit's desktop controls around [child] are up, +/// by the same rule they use: up on mount and on any mouse movement, +/// down [hideAfter] later or when the mouse leaves. media_kit does not +/// expose its visibility; this lets the player draw a progress line while +/// they are down. Never takes input. +class ControlsHoverProbe extends StatefulWidget { + final ValueNotifier shown; + final Duration hideAfter; + final Widget child; + const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child}); + + @override + State createState() => _ControlsHoverProbeState(); +} + +class _ControlsHoverProbeState extends State { + Timer? _timer; + + @override + void initState() { + super.initState(); + // Not during this build: the line listening to [shown] may be built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _wake(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _wake() { + widget.shown.value = true; + _timer?.cancel(); + _timer = Timer(widget.hideAfter, _hide); + } + + void _hide() { + _timer?.cancel(); + if (mounted) widget.shown.value = false; + } + + @override + Widget build(BuildContext context) => MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + onEnter: (_) => _wake(), + onHover: (_) => _wake(), + onExit: (_) => _hide(), + child: widget.child, + ); +} diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart new file mode 100644 index 0000000..e86b1ae --- /dev/null +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -0,0 +1,407 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; + +import '../jellyfin/track_prefs.dart' show MpvTrack; +import '../playback/playback_controller.dart' show PlaybackController, RenderMode; +import '../shaders/shader_presets.dart'; +import '../state/session_state.dart'; +import '../shell/motion.dart'; +import '../state/settings.dart'; +import 'uosc/buttons.dart'; +import 'uosc/controls.dart'; + +// Everything about the player, defined once: the rows of the ⚙ card in +// both control designs and of Settings → Player. Each row reads and +// writes [AppSettings] and applies to a running player right away. + +/// One setting: a label, one line of help under it, the control at the +/// right ([below]: an extra line under the row, e.g. a text field). +class SettingRow extends StatelessWidget { + final String label; + final String? help; + final Widget control; + final Widget? below; + const SettingRow({super.key, required this.label, this.help, required this.control, this.below}); + + @override + Widget build(BuildContext context) { + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + if (help != null) Text(help!, style: TextStyle(color: muted, fontSize: 12)), + ], + ), + ), + const SizedBox(width: 12), + control, + ], + ), + ?below, + ], + ), + ); + } +} + +DropdownButton _dropdown({ + required Key key, + required T value, + required List<(T, String)> items, + required ValueChanged onChanged, + bool expanded = false, +}) => + DropdownButton( + key: key, + value: value, + isExpanded: expanded, + underline: const SizedBox.shrink(), + borderRadius: BorderRadius.circular(10), + items: [ + for (final (v, label) in items) + DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ); + +/// Standard (our bars over media_kit's controls) or uosc. Live. +class PlayerDesignSetting extends StatelessWidget { + const PlayerDesignSetting({super.key}); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SettingRow( + label: 'Player design', + help: 'uosc is modeled on mpv: a thick timeline with chapters and what is cached', + control: _dropdown( + key: const ValueKey('player-design'), + value: s.mpvControls.value, + items: const [(false, 'Standard'), (true, 'uosc')], + onChanged: s.setMpvControls, + ), + ), + ); + } +} + +/// The 2 px line at the bottom while the controls are hidden. Live. +class ProgressLineSetting extends StatelessWidget { + const ProgressLineSetting({super.key}); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SettingRow( + label: 'Progress line when hidden', + help: 'A thin bar at the bottom of the video while the controls are hidden', + control: Switch( + key: const ValueKey('progress-line'), + value: s.progressLine.value, + onChanged: s.setProgressLine, + ), + ), + ); + } +} + +/// Saves a shader choice (also used on the next join) and puts it on the +/// running player, if any; the player announces the result. +void applyShaderChoice(ShaderPreset preset, ShaderQuality? quality) { + final s = AppSettings.instance; + if (quality != null) s.setShaderQuality(quality); + s.setShaderPreset(preset); + final coord = SessionState.instance.coordinator; + coord.markInput(); + coord.playback.applyShaders(announce: true); +} + +/// Anime4K preset (or custom files) and HQ / Fast. Live. +class ShaderPresetSetting extends StatelessWidget { + /// The running player's render mode (in the player); else the last one. + final ReadonlySignal? renderMode; + const ShaderPresetSetting({super.key, this.renderMode}); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder(builder: (context) { + final preset = s.shaderPreset.value; + final quality = s.shaderQuality.value; + final hasCustom = s.customShaders.value.isNotEmpty; + final mode = (renderMode ?? PlaybackController.lastRenderMode).value; + final warning = switch (mode) { + RenderMode.software => 'Software rendering: shaders have no effect', + RenderMode.softwareGl => 'OpenGL on the CPU: shaders will be slow', + _ => null, + }; + return SettingRow( + label: 'Upscaling shader', + help: 'Anime4K sharpens anime on your GPU, only you see it. ' + 'A for 1080p, B for 720p, C for 480p · Ctrl+1…6, Ctrl+0 off', + control: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _dropdown( + key: const ValueKey('shader-preset'), + value: preset, + items: [ + for (final p in ShaderPreset.values) + if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label), + ], + onChanged: (p) => applyShaderChoice(p, null), + ), + if (preset.isAnime4k) ...[ + const SizedBox(width: 8), + _dropdown( + key: const ValueKey('shader-quality'), + value: quality, + items: [for (final q in ShaderQuality.values) (q, q.label)], + onChanged: (q) => applyShaderChoice(preset, q), + ), + ], + ], + ), + below: warning == null + ? null + : Text(warning, style: const TextStyle(fontSize: 12, color: Colors.orange)), + ); + }); + } +} + +/// Puts the hwdec setting on the running player (mpv switches decoders +/// on the fly). `SYNC_RELAY_HWDEC` wins, as at player start. +Future applyHwdecLive() async { + if ((Platform.environment['SYNC_RELAY_HWDEC'] ?? '').isNotEmpty) return; + final playback = SessionState.instance.coordinator.playback; + if (!playback.alive.peek()) return; + try { + final dynamic platform = playback.player.platform; + await platform?.setProperty('hwdec', AppSettings.instance.hwdec.peek()); + } catch (e) { + debugPrint('live hwdec failed: $e'); + } +} + +/// mpv's `hwdec`: auto, off, or a custom value. Live. +class HwdecSetting extends StatefulWidget { + const HwdecSetting({super.key}); + + @override + State createState() => _HwdecSettingState(); +} + +class _HwdecSettingState extends State { + final _s = AppSettings.instance; + late final _custom = TextEditingController(text: _s.hwdecCustom.peek()); + + @override + void dispose() { + _custom.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SignalBuilder(builder: (context) { + final mode = _s.hwdecMode.value; + return SettingRow( + label: 'Hardware decoding', + help: 'Decodes video on the GPU. Turn it off if the picture glitches or stays black', + control: _dropdown( + key: const ValueKey('hwdec'), + value: mode, + items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')], + onChanged: (m) { + _s.setHwdecMode(m); + applyHwdecLive(); + }, + ), + below: mode != HwdecMode.custom + ? null + : Padding( + padding: const EdgeInsets.only(top: 8), + child: TextField( + controller: _custom, + decoration: const InputDecoration( + isDense: true, + labelText: 'mpv hwdec value (Enter applies)', + hintText: 'e.g. vaapi, nvdec, d3d11va, auto-safe', + border: OutlineInputBorder(), + ), + onChanged: _s.setHwdecCustom, + onSubmitted: (_) => applyHwdecLive(), + ), + ), + ); + }); + } +} + +/// Audio or subtitle track of the running player (in the player only). +/// The pick is remembered for the next Jellyfin item. +class TrackSetting extends StatelessWidget { + final JellyfinStreamType type; + const TrackSetting({super.key, required this.type}); + + bool get _audio => type == JellyfinStreamType.audio; + + @override + Widget build(BuildContext context) { + final coord = SessionState.instance.coordinator; + return SignalBuilder(builder: (context) { + final list = [ + for (final t in coord.playback.tracks.value) + if (t.type == type) t, + ]; + // -1: subtitles off. + final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1); + final items = <(int, String)>[ + if (!_audio) (-1, 'Off'), + for (final MpvTrack t in list) (t.id, t.label), + ]; + return SettingRow( + label: _audio ? 'Audio' : 'Subtitles', + help: 'Remembered for the next episode', + control: SizedBox( + width: 190, + child: items.isEmpty || selected == null + ? Text(items.isEmpty ? 'None' : 'Pick one…', + key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end) + : _dropdown( + key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), + value: selected, + expanded: true, + items: items, + onChanged: (id) { + coord.markInput(); + if (id == -1) { + coord.selectTrack(subtitlesOff: true); + } else if (_audio) { + coord.selectTrack(aid: id); + } else { + coord.selectTrack(sid: id); + } + }, + ), + ), + ); + }); + } +} + +/// The player rows of Settings → Player and the ⚙ card, in order. +List playerSettingRows({ReadonlySignal? renderMode}) => [ + const PlayerDesignSetting(), + const ProgressLineSetting(), + ShaderPresetSetting(renderMode: renderMode), + const HwdecSetting(), + ]; + +/// The ⚙ card: every player setting, plus the running player's tracks. +class PlayerSettingsCard extends StatelessWidget { + final ReadonlySignal? renderMode; + /// Show the audio / subtitle pickers (a player is running). + final bool tracks; + const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true}); + + @override + Widget build(BuildContext context) { + return Material( + key: const ValueKey('player-settings-card'), + elevation: 12, + borderRadius: BorderRadius.circular(16), + child: SizedBox( + width: 420, + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Player', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)), + const SizedBox(height: 4), + ...playerSettingRows(renderMode: renderMode), + if (tracks) ...[ + const Divider(), + const TrackSetting(type: JellyfinStreamType.subtitle), + const TrackSetting(type: JellyfinStreamType.audio), + ], + const SizedBox(height: 4), + Text( + 'Custom shader files: Settings → Player', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), + ), + ], + ), + ), + ), + ); + } +} + +/// Opens the ⚙ card over the player, near the controls' right end. The +/// uosc controls stay up while it is open. +Future showPlayerSettings(BuildContext context, {ReadonlySignal? renderMode}) { + Future open() => showPopover( + context, + at: const Alignment(0.96, 0.5), + child: PlayerSettingsCard(renderMode: renderMode), + ); + final controls = UoscControls.maybeOf(context); + return controls == null ? open() : controls.holdDuring(open); +} + +/// ⚙ in the standard controls' bottom bar. +class PlayerSettingsButton extends StatelessWidget { + final FocusNode? focusNode; + final ReadonlySignal? renderMode; + const PlayerSettingsButton({super.key, this.focusNode, this.renderMode}); + + @override + Widget build(BuildContext context) { + return IconButton( + key: const ValueKey('player-settings'), + focusNode: focusNode, + tooltip: 'Player settings', + color: Colors.white, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.tune), + onPressed: () => showPlayerSettings(context, renderMode: renderMode), + ); + } +} + +/// ⚙ in the uosc controls' button row. +class UoscPlayerSettingsButton extends StatelessWidget { + final ReadonlySignal? renderMode; + const UoscPlayerSettingsButton({super.key, this.renderMode}); + + @override + Widget build(BuildContext context) { + return UoscButton( + key: const ValueKey('uosc-player-settings'), + icon: Icons.tune_rounded, + tooltip: 'Player settings', + onPressed: () => showPlayerSettings(context, renderMode: renderMode), + ); + } +} diff --git a/apps/desktop/lib/src/controls/uosc/controls.dart b/apps/desktop/lib/src/controls/uosc/controls.dart index c505610..51c022e 100644 --- a/apps/desktop/lib/src/controls/uosc/controls.dart +++ b/apps/desktop/lib/src/controls/uosc/controls.dart @@ -5,11 +5,12 @@ import 'package:signals/signals_flutter.dart'; import '../../state/settings.dart'; -// The experimental mpv-style (uosc) controls frame: a full-width timeline -// at the bottom with a row of flat buttons above it, a corner widget top -// right. Mouse movement shows them; after [UoscControls.hideAfter] without -// any they fade out, the timeline shrinks to a thin line and the cursor -// hides. Our own frame: no media_kit controls state to reach into. +// The mpv-style (uosc) controls frame: a full-width timeline at the +// bottom with a row of flat buttons above it, a corner widget top right. +// Mouse movement shows them; after [UoscControls.hideAfter] without any +// they fade out, the timeline shrinks to a 2 px line (gone when "Progress +// line when hidden" is off) and the cursor hides. Our own frame: no +// media_kit controls state to reach into. class UoscControls extends StatefulWidget { /// The timeline; [expanded] is false while the controls are hidden. @@ -167,7 +168,19 @@ class UoscControlsState extends State { ), ), ), - widget.timeline(context, visible), + // Hidden: the collapsed timeline is the progress line, + // unless the player settings turn it off. + SignalBuilder( + builder: (context) { + final line = AppSettings.instance.progressLine.value; + return AnimatedOpacity( + key: const ValueKey('uosc-timeline-wrap'), + duration: fade, + opacity: visible || line ? 1 : 0, + child: widget.timeline(context, visible), + ); + }, + ), ], ), ), @@ -177,9 +190,9 @@ class UoscControlsState extends State { } } -/// The session's controls: media_kit's ([material]) or, with Settings → -/// "Experimental: mpv-style controls", the uosc ones ([mpv]). Switches -/// live when the setting changes. +/// The session's controls: the standard ones ([material]) or, with the +/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live +/// when the setting changes. class SessionControlsSwitch extends StatelessWidget { final WidgetBuilder material; final WidgetBuilder mpv; diff --git a/apps/desktop/lib/src/controls/uosc/timeline.dart b/apps/desktop/lib/src/controls/uosc/timeline.dart index 19856a1..4e01d9e 100644 --- a/apps/desktop/lib/src/controls/uosc/timeline.dart +++ b/apps/desktop/lib/src/controls/uosc/timeline.dart @@ -59,7 +59,7 @@ class UoscTimeline extends StatefulWidget { }); static const height = 30.0; - static const collapsedHeight = 3.0; + static const collapsedHeight = 2.0; @override State createState() => _UoscTimelineState(); diff --git a/apps/desktop/lib/src/jellyfin/accounts.dart b/apps/desktop/lib/src/jellyfin/accounts.dart index 0b0bc58..ff36ac9 100644 --- a/apps/desktop/lib/src/jellyfin/accounts.dart +++ b/apps/desktop/lib/src/jellyfin/accounts.dart @@ -24,6 +24,7 @@ const _keyAccounts = 'accounts'; const _keyDefault = 'defaultServer'; const _keyLastPick = 'lastPick'; const _keyBrowsePrefix = 'browse:'; +const _keyDismissedPrefix = 'dismissed:'; /// One signed-in user on one server. class JellyfinAccount { @@ -484,6 +485,38 @@ class JellyfinAccounts extends ChangeNotifier { } } + /// Continue-row entries removed by hand on [serverId] (key -> when), so a + /// next-up series (which Jellyfin cannot drop) stays out until it is + /// played again. Keys are `continueKey`s. + Map continueDismissed(String serverId) { + try { + final v = _store.get('$_keyDismissedPrefix$serverId'); + final j = v is String ? _tryJson(v) : null; + if (j is! Map) return const {}; + return { + for (final e in j.entries) + if (e.key is String && e.value is int) + e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int, isUtc: true), + }; + } catch (_) { + return const {}; + } + } + + /// Keeps [key] out of the Continue row on [serverId] until it is played + /// after [at] (default now). The newest 200 are kept. + Future dismissContinue(String serverId, String key, {DateTime? at}) async { + final all = {...continueDismissed(serverId), key: (at ?? DateTime.now()).toUtc()}; + final newest = all.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + try { + await _store.put('$_keyDismissedPrefix$serverId', jsonEncode({ + for (final e in newest.take(200)) e.key: e.value.millisecondsSinceEpoch, + })); + } catch (e) { + debugPrint('save dismissed continue failed: $e'); + } + } + // --------------------------------------------------------------- cache /// Short-lived memo for browse calls, so going back does not refetch. diff --git a/apps/desktop/lib/src/jellyfin/browse_scope.dart b/apps/desktop/lib/src/jellyfin/browse_scope.dart index 044af4c..88ae9db 100644 --- a/apps/desktop/lib/src/jellyfin/browse_scope.dart +++ b/apps/desktop/lib/src/jellyfin/browse_scope.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sync_relay_core/core.dart'; @@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute { String get key => 'home'; } -/// A library or folder grid. +/// A library or folder grid, optionally opened with a sort ("Show all" +/// next to Latest opens it newest first). final class LibraryRoute extends BrowseRoute { final JellyfinItem folder; - const LibraryRoute(this.folder); + /// A `LibrarySort` name; null = by name. + final String? sort; + const LibraryRoute(this.folder, {this.sort}); @override String get label => folder.name; @override - String get key => 'lib:${folder.id}'; + String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort'; } final class SeriesRoute extends BrowseRoute { @@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute { String get key => 'series:${series.id}'; } -/// Movie / episode / video detail. -final class DetailRoute extends BrowseRoute { - final JellyfinItem item; - const DetailRoute(this.item); - @override - String get label => item.kind == JellyfinItemKind.episode - ? (item.episodeLabel ?? item.name) - : item.name; - @override - String get key => 'item:${item.id}'; -} - /// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only /// ids, names and the fields the pages start from (each page loads the /// full item itself). @@ -60,18 +54,17 @@ List> encodeBrowseStack(List stack) => [ for (final r in stack) switch (r) { HomeRoute() => {'r': 'home'}, - LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)}, + LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort}, SeriesRoute(:final series, :final seasonId) => { 'r': 'series', ..._itemJson(series), 'seasonId': ?seasonId, }, - DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)}, } ]; /// Inverse of [encodeBrowseStack]; always starts with Home. Null when -/// [raw] is not a stack. +/// [raw] is not a stack. Entries it no longer knows are skipped. List? decodeBrowseStack(Object? raw) { if (raw is! List) return null; final out = [const HomeRoute()]; @@ -79,11 +72,10 @@ List? decodeBrowseStack(Object? raw) { if (e is! Map) return null; final item = _itemFrom(e); final route = switch (e['r']) { - 'home' => null, - 'lib' when item != null => LibraryRoute(item), + 'lib' when item != null => + LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null), 'series' when item != null => SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null), - 'item' when item != null => DetailRoute(item), _ => null, }; if (route != null && out.every((r) => r.key != route.key)) out.add(route); @@ -141,46 +133,181 @@ class BrowseStart { } } +/// The card selected in the library (details panel): an item on a server. +/// [item] is what the card had, when known (the panel loads it in full). +class JellyfinSelection { + final String serverId; + final String itemId; + final JellyfinItem? item; + const JellyfinSelection(this.serverId, this.itemId, [this.item]); + + @override + bool operator ==(Object other) => + other is JellyfinSelection && other.serverId == serverId && other.itemId == itemId; + @override + int get hashCode => Object.hash(serverId, itemId); +} + +/// Browser location shared by the browser, the library chips in the +/// Library header and the details panel: the server shown, its route +/// stack (saved per server on every move), a data version that bumps after +/// watched-state changes (rows reload), and the keys in the Continue row. +class BrowseController extends ChangeNotifier { + final JellyfinAccounts accounts; + /// Clock for the Continue row's age cut-off (tests pin it). + final DateTime Function() now; + BrowseController(this.accounts, {this.now = DateTime.now}); + + String? _serverId; + List _stack = const [HomeRoute()]; + bool _started = false; + int _version = 0; + Set _continueKeys = const {}; + + List get stack => _stack; + int get version => _version; + /// [continueKey]s of the cards in the Continue row now. + Set get continueKeys => _continueKeys; + + JellyfinAccount? get account => + (_serverId == null ? null : accounts.account(_serverId!)) ?? accounts.defaultAccount; + String? get serverId => account?.serverId; + + /// First use (accounts loaded): [start], else the server of [serverHint] + /// at its remembered location, else the default server's. + void start({BrowseStart? start, String? serverHint}) { + if (_started) return; + _started = true; + if (start != null && accounts.account(start.serverId) != null) { + _serverId = start.serverId; + _stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack; + _remember(); + } else { + _serverId = serverHint != null && accounts.account(serverHint) != null + ? serverHint + : accounts.defaultServerId; + _stack = _restored(_serverId); + } + } + + List _restored(String? serverId) => + (serverId == null ? null : decodeBrowseStack(accounts.browseLocation(serverId))) ?? + const [HomeRoute()]; + + void _remember() { + final id = serverId; + if (id != null) unawaited(accounts.saveBrowseLocation(id, encodeBrowseStack(_stack))); + } + + void switchServer(String serverId) { + if (serverId == this.serverId) return; + _serverId = serverId; + _stack = _restored(serverId); + _continueKeys = const {}; + notifyListeners(); + } + + void open(BrowseRoute route) { + if (_stack.last.key == route.key) return; + _stack = [..._stack, route]; + _remember(); + notifyListeners(); + } + + /// Home, then [route] (library chips: a library is a top-level place). + void openTop(BrowseRoute route) { + _stack = [const HomeRoute(), route]; + _remember(); + notifyListeners(); + } + + bool back() { + if (_stack.length <= 1) return false; + _stack = _stack.sublist(0, _stack.length - 1); + _remember(); + notifyListeners(); + return true; + } + + void popTo(int index) { + _stack = _stack.sublist(0, index + 1); + _remember(); + notifyListeners(); + } + + /// Watched state changed on the server: drop its cached lists and let + /// the rows reload. + void dataChanged() { + final id = serverId; + if (id != null) accounts.invalidate(id); + _version++; + notifyListeners(); + } + + set continueKeys(Set keys) { + if (setEquals(keys, _continueKeys)) return; + _continueKeys = keys; + // Set while building a row: tell listeners after the frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed) notifyListeners(); + }); + } + + bool _disposed = false; + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + /// What every browser page needs, provided by [JellyfinBrowser]. class BrowseScope extends InheritedWidget { final JellyfinAccounts accounts; final JellyfinAccount account; final JellyfinClient client; - /// Item id of the current pick on this server, highlighted everywhere. + final BrowseController controller; + /// Item id of the selected card on this server, highlighted everywhere. final String? selectedItemId; - final ValueChanged onPick; - final ValueChanged onOpen; - /// Hover / focus: the inspector pane follows it. - final ValueChanged onPeek; + /// Click: select (details panel). + final ValueChanged onSelect; + /// Double-click: put it up (a series: its next episode). + final ValueChanged onPutUp; + /// Home shows the library chips itself (the Library tab has them in its + /// header instead). + final bool libraryStrip; const BrowseScope({ super.key, required this.accounts, required this.account, required this.client, + required this.controller, required this.selectedItemId, - required this.onPick, - required this.onOpen, - required this.onPeek, + required this.onSelect, + required this.onPutUp, + this.libraryStrip = true, required super.child, }); static BrowseScope of(BuildContext context) => context.dependOnInheritedWidgetOfExactType()!; + void onOpen(BrowseRoute route) => controller.open(route); + /// Cached browse call on this server. Future load(String key, Future Function(JellyfinClient c) fn) => accounts.cached(account.serverId, key, () => fn(client)); - /// Click / Enter on a card: playable items are picked, containers open. + /// Click / Enter on a card: series, movies and episodes are selected, + /// seasons open their series, libraries and folders open. void activate(JellyfinItem item) { - if (isPlayable(item)) { - onPick(item); + if (isSelectable(item)) { + onSelect(item); return; } switch (item.kind) { - case JellyfinItemKind.series: - onOpen(SeriesRoute(item)); case JellyfinItemKind.season: final sid = item.seriesId; if (sid != null) { @@ -193,7 +320,7 @@ class BrowseScope extends InheritedWidget { } } - /// Opens the series of an episode (or the item itself if it has none). + /// Opens the series of an episode. void openSeriesOf(JellyfinItem episode) { final sid = episode.seriesId; if (sid == null) return; @@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget { bool updateShouldNotify(BrowseScope old) => old.selectedItemId != selectedItemId || old.account != account || - old.client != client; + old.client != client || + old.libraryStrip != libraryStrip; } -/// Runs [load] once (again on [retry]) and shows [builder] with the result, -/// a spinner meanwhile, and a compact error with Retry on failure. +/// Runs [load] once (again on [retry] and when [version] changes) and +/// shows [builder] with the result, a spinner meanwhile, and a compact +/// error with Retry on failure. A reload for a new [version] keeps the old +/// result on screen until the new one arrives. class Loader extends StatefulWidget { final Future Function() load; final Widget Function(BuildContext context, T data) builder; final Widget? loading; /// When false the error collapses to nothing (optional sections). final bool showError; + final Object? version; const Loader({ super.key, required this.load, required this.builder, this.loading, this.showError = true, + this.version, }); @override @@ -231,22 +363,31 @@ class Loader extends StatefulWidget { class _LoaderState extends State> { late Future _future = widget.load(); + (T,)? _last; void _retry() => setState(() => _future = widget.load()); + @override + void didUpdateWidget(Loader old) { + super.didUpdateWidget(old); + if (old.version != widget.version) _future = widget.load(); + } + @override Widget build(BuildContext context) { return FutureBuilder( future: _future, builder: (context, snap) { - if (snap.hasData) return widget.builder(context, snap.data as T); + if (snap.hasData || (snap.connectionState == ConnectionState.done && !snap.hasError)) { + _last = (snap.data as T,); + return widget.builder(context, snap.data as T); + } if (snap.hasError) { if (!widget.showError) return const SizedBox.shrink(); return LoadError(error: snap.error!, onRetry: _retry); } - if (snap.connectionState == ConnectionState.done) { - return widget.builder(context, snap.data as T); - } + final last = _last; + if (last != null) return widget.builder(context, last.$1); return widget.loading ?? const Padding( padding: EdgeInsets.all(32), diff --git a/apps/desktop/lib/src/jellyfin/browser.dart b/apps/desktop/lib/src/jellyfin/browser.dart index 56561d7..da78cff 100644 --- a/apps/desktop/lib/src/jellyfin/browser.dart +++ b/apps/desktop/lib/src/jellyfin/browser.dart @@ -2,52 +2,51 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:sync_relay_core/core.dart'; -import '../screens/home/picked_media.dart'; import 'accounts.dart'; import 'browse_scope.dart'; -import 'detail_page.dart'; -import 'format.dart'; import 'home_page.dart'; -import 'inspector.dart'; import 'library_page.dart'; import 'search_results.dart'; import 'series_page.dart'; import 'sign_in_panel.dart'; -/// Browser width from which the inspector pane shows on the right. -const inspectorFrom = 1400.0; -const inspectorWidth = 340.0; - /// Library browser for the linked Jellyfin servers: server switcher, /// breadcrumbs, search, and a back stack of pages (Home rows, library -/// grids, series, details). Pages below the top stay alive, so going back -/// keeps scroll position and loaded pages. +/// grids, series). Pages below the top stay alive, so going back keeps +/// scroll position and loaded pages. Where it is lives in a +/// [BrowseController] (shared with the Library header's library chips and +/// the details panel; one of its own when none is given). /// -/// Keyboard: arrows move between cards, Enter picks / opens, I opens -/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches. +/// A click on a card selects it ([onSelect]; libraries and folders open), +/// a double-click puts it up ([onPutUp]). +/// +/// Keyboard: arrows move between cards, Enter selects / opens, Esc / +/// Backspace / Alt+Left go back, / or Ctrl+F searches. class JellyfinBrowser extends StatefulWidget { final JellyfinAccounts accounts; - final JellyfinMedia? selected; - final ValueChanged onPick; - /// Shown when given: toggles a bigger browser (the host hides its form). - final VoidCallback? onToggleExpanded; - final bool expanded; + final BrowseController? controller; + /// Highlighted card. + final JellyfinSelection? selected; + final ValueChanged onSelect; + final ValueChanged onPutUp; final VoidCallback? onOpenIntegrations; final Duration searchDebounce; /// Opens here instead of the remembered location (the series of the - /// episode playing, in the session's swap panel). + /// episode playing, after "Change video"). final BrowseStart? start; + /// Library chips on Home (off when the Library header shows them). + final bool libraryStrip; const JellyfinBrowser({ this.start, super.key, required this.accounts, - required this.onPick, + required this.onSelect, + required this.onPutUp, + this.controller, this.selected, - this.onToggleExpanded, - this.expanded = false, this.onOpenIntegrations, + this.libraryStrip = true, this.searchDebounce = const Duration(milliseconds: 300), }); @@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget { } class _JellyfinBrowserState extends State { - String? _serverId; - List _stack = const [HomeRoute()]; + BrowseController? _own; final _search = TextEditingController(); final _searchFocus = FocusNode(debugLabel: 'jf-search'); final _body = FocusScopeNode(debugLabel: 'jf-body'); - final _peek = ValueNotifier(null); String _term = ''; Timer? _debounce; int _refresh = 0; JellyfinAccounts get _accounts => widget.accounts; + BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts)); @override void initState() { super.initState(); - final start = widget.start; - if (start != null && _accounts.account(start.serverId) != null) { - _serverId = start.serverId; - _stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack; - _remember(); - } else { - _serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null - ? widget.selected!.serverId - : _accounts.defaultServerId; - _stack = _restored(_serverId); - } + _c.start(start: widget.start, serverHint: widget.selected?.serverId); _accounts.addListener(_changed); - } - - /// The remembered location on [serverId], else Home. - List _restored(String? serverId) => - (serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ?? - const [HomeRoute()]; - - /// Every navigation is saved per server (Hive), so the next browser - /// (the Library tab, next start) opens where this one was. - void _remember() { - final id = _account?.serverId; - if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack))); + _c.addListener(_moved); } @override @@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State { old.accounts.removeListener(_changed); widget.accounts.addListener(_changed); } + final was = old.controller ?? _own; + if (was != _c) { + was?.removeListener(_moved); + _c.addListener(_moved); + } } @override void dispose() { _accounts.removeListener(_changed); + _c.removeListener(_moved); + _own?.dispose(); _debounce?.cancel(); _search.dispose(); _searchFocus.dispose(); _body.dispose(); - _peek.dispose(); super.dispose(); } @@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State { if (mounted) setState(() {}); } - JellyfinAccount? get _account => - (_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount; - - void _switchServer(String serverId) { - if (serverId == _account?.serverId) return; + /// Any move (here, from the library chips or a server switch) ends a + /// search. + int _depth = 0; + String? _server; + void _moved() { + if (!mounted) return; + final moved = _c.stack.length != _depth || _c.serverId != _server; + _depth = _c.stack.length; + _server = _c.serverId; setState(() { - _serverId = serverId; - _stack = _restored(serverId); - _peek.value = null; - _clearSearch(); + if (moved) _clearSearch(); }); } + JellyfinAccount? get _account => _c.account; + // ------------------------------------------------------------- navigation - void _open(BrowseRoute route) { - setState(() { - _clearSearch(); - if (_stack.last.key != route.key) _stack = [..._stack, route]; - }); - _remember(); - _body.requestFocus(); - } - bool _back() { - if (_stack.length <= 1) return false; - setState(() => _stack = _stack.sublist(0, _stack.length - 1)); - _remember(); - _body.requestFocus(); - return true; + final ok = _c.back(); + if (ok) _body.requestFocus(); + return ok; } void _popTo(int index) { - setState(() { - _clearSearch(); - _stack = _stack.sublist(0, index + 1); - }); - _remember(); + setState(_clearSearch); + _c.popTo(index); _body.requestFocus(); } @@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State { _term = ''; } - void _pick(JellyfinItem item) { - final account = _account; - if (account == null) return; - widget.onPick(pickFor(account, item)); - } - KeyEventResult _onKey(FocusNode node, KeyEvent e) { if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored; final keys = HardwareKeyboard.instance; @@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State { Widget _page(BrowseRoute r) => switch (r) { HomeRoute() => const JellyfinHomePage(), - LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder), + LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort), SeriesRoute(:final series, :final seasonId) => JellyfinSeriesPage(series: series, seasonId: seasonId), - DetailRoute(:final item) => JellyfinDetailPage(item: item), }; @override @@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State { final status = _accounts.statusOf(account.serverId); final sel = widget.selected; final searching = _term.isNotEmpty; + final stack = _c.stack; final pages = Stack( children: [ - for (var i = 0; i < _stack.length; i++) + for (var i = 0; i < stack.length; i++) Visibility( - key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'), - visible: !searching && i == _stack.length - 1, + key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'), + visible: !searching && i == stack.length - 1, maintainState: true, - child: _page(_stack[i]), + child: _page(stack[i]), ), if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term), ], @@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State { accounts: _accounts, account: account, client: client, + controller: _c, selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null, - onPick: _pick, - onOpen: _open, - onPeek: (item) => _peek.value = item, + onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)), + onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)), + libraryStrip: widget.libraryStrip, child: Focus( onKeyEvent: _onKey, child: Column( @@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State { if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline) _StatusBanner(accounts: _accounts, account: account, status: status), const SizedBox(height: 12), - Expanded( - child: LayoutBuilder(builder: (context, box) { - final inspector = box.maxWidth >= inspectorFrom; - final body = FocusScope(node: _body, child: pages); - if (!inspector) return body; - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: body), - const SizedBox(width: 16), - SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)), - ], - ); - }), - ), + Expanded(child: FocusScope(node: _body, child: pages)), ], ), ), @@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State { Widget _header(BuildContext context, JellyfinAccount account) { final theme = Theme.of(context); final t = theme.textTheme; + final stack = _c.stack; final crumbs = []; - for (var i = 0; i < _stack.length; i++) { - final last = i == _stack.length - 1 && _term.isEmpty; + for (var i = 0; i < stack.length; i++) { + final last = i == stack.length - 1 && _term.isEmpty; if (i > 0) { crumbs.add(const Padding( padding: EdgeInsets.symmetric(horizontal: 2), @@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State { )); } final label = Text( - _stack[i].label, + stack[i].label, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall?.copyWith( @@ -328,73 +282,68 @@ class _JellyfinBrowserState extends State { style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600)))); } - return Row( - children: [ - _ServerSwitcher( - accounts: _accounts, - current: account, - onSelect: _switchServer, - onOpenIntegrations: widget.onOpenIntegrations, - ), - const SizedBox(width: 4), - IconButton( - tooltip: 'Back (Esc)', - visualDensity: VisualDensity.compact, - onPressed: _stack.length > 1 ? _back : null, - icon: const Icon(Icons.arrow_back, size: 20), - ), - const SizedBox(width: 4), - Expanded(child: Row(children: crumbs)), - const SizedBox(width: 8), - SizedBox( - width: 260, - child: TextField( - key: const ValueKey('jf-search'), - controller: _search, - focusNode: _searchFocus, - onChanged: _onSearchChanged, - onSubmitted: (v) { - _debounce?.cancel(); - setState(() => _term = v.trim()); - }, - decoration: InputDecoration( - isDense: true, - hintText: 'Search ( / )', - prefixIcon: const Icon(Icons.search, size: 18), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - suffixIcon: ListenableBuilder( - listenable: _search, - builder: (context, _) => _search.text.isEmpty - ? const SizedBox.shrink() - : IconButton( - tooltip: 'Clear (Esc)', - iconSize: 16, - onPressed: () => setState(_clearSearch), - icon: const Icon(Icons.close), - ), + return LayoutBuilder(builder: (context, box) { + final narrow = box.maxWidth < 640; + return Row( + children: [ + _ServerSwitcher( + accounts: _accounts, + current: account, + compact: narrow, + onSelect: _c.switchServer, + onOpenIntegrations: widget.onOpenIntegrations, + ), + IconButton( + tooltip: 'Back (Esc)', + visualDensity: VisualDensity.compact, + onPressed: stack.length > 1 ? _back : null, + icon: const Icon(Icons.arrow_back, size: 20), + ), + const SizedBox(width: 4), + Expanded(child: Row(children: crumbs)), + const SizedBox(width: 8), + SizedBox( + width: narrow ? 170 : 260, + child: TextField( + key: const ValueKey('jf-search'), + controller: _search, + focusNode: _searchFocus, + onChanged: _onSearchChanged, + onSubmitted: (v) { + _debounce?.cancel(); + setState(() => _term = v.trim()); + }, + decoration: InputDecoration( + isDense: true, + hintText: 'Search ( / )', + prefixIcon: const Icon(Icons.search, size: 18), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + suffixIcon: ListenableBuilder( + listenable: _search, + builder: (context, _) => _search.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + tooltip: 'Clear (Esc)', + iconSize: 16, + onPressed: () => setState(_clearSearch), + icon: const Icon(Icons.close), + ), + ), ), ), ), - ), - IconButton( - tooltip: 'Refresh', - visualDensity: VisualDensity.compact, - onPressed: () { - _accounts.invalidate(account.serverId); - setState(() => _refresh++); - }, - icon: const Icon(Icons.refresh, size: 20), - ), - if (widget.onToggleExpanded != null) IconButton( - key: const ValueKey('jf-expand'), - tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser', + tooltip: 'Refresh', visualDensity: VisualDensity.compact, - onPressed: widget.onToggleExpanded, - icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18), + onPressed: () { + _accounts.invalidate(account.serverId); + setState(() => _refresh++); + }, + icon: const Icon(Icons.refresh, size: 20), ), - ], - ); + ], + ); + }); } } @@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget { final JellyfinAccount current; final ValueChanged onSelect; final VoidCallback? onOpenIntegrations; + /// Narrow: the name is shorter. + final bool compact; const _ServerSwitcher({ required this.accounts, required this.current, required this.onSelect, this.onOpenIntegrations, + this.compact = false, }); @override @@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget { StatusDot(status: accounts.statusOf(current.serverId)), const SizedBox(width: 8), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), + constraints: BoxConstraints(maxWidth: compact ? 110 : 200), child: Text(current.displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall), ), diff --git a/apps/desktop/lib/src/jellyfin/cards.dart b/apps/desktop/lib/src/jellyfin/cards.dart index 91d3059..aedab39 100644 --- a/apps/desktop/lib/src/jellyfin/cards.dart +++ b/apps/desktop/lib/src/jellyfin/cards.dart @@ -2,12 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:sync_relay_core/core.dart'; import 'browse_scope.dart'; import 'format.dart'; import 'images.dart'; +import '../shell/motion.dart' show HScroll; enum CardShape { /// 2:3 poster (movies, series, latest). @@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) { _ => Icons.movie_outlined, }; +/// Two clicks on the same card within this time put it up. +const doubleClickTime = Duration(milliseconds: 350); + /// Poster or thumb card: image with progress bar and state badges, title -/// and one meta line. Click / Enter activates (pick or open), hover and -/// focus feed the inspector, "i" or the hover button opens the details. +/// and one meta line. Click / Enter selects it (details panel; libraries +/// and folders open), a second click soon after puts it up. [display] +/// gives the picture and title when they are not [item]'s own (a series +/// card for a new episode), [subtitle] replaces the meta line. class MediaCard extends StatefulWidget { final JellyfinItem item; + final JellyfinItem? display; + final String? subtitle; final CardShape shape; - /// Rows mixing series (Continue watching, Next up): series name on top, - /// "S2E3 · 12 min left" below. + /// Rows mixing series (Continue): series name on top, "S2E3 · 12 min + /// left" below. final bool seriesFirst; final bool autofocus; const MediaCard({ super.key, required this.item, required this.shape, + this.display, + this.subtitle, this.seriesFirst = false, this.autofocus = false, }); @@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget { class _MediaCardState extends State { bool _hover = false; bool _focus = false; + DateTime? _lastTap; JellyfinItem get item => widget.item; - void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item)); - - KeyEventResult _onKey(BrowseScope scope, KeyEvent e) { - if (e is! KeyDownEvent) return KeyEventResult.ignored; - if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) { - _details(scope); - return KeyEventResult.handled; + void _tap(BrowseScope scope) { + final now = DateTime.now(); + final last = _lastTap; + _lastTap = now; + if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) { + _lastTap = null; + scope.onPutUp(item); + return; } - return KeyEventResult.ignored; + scope.activate(item); } @override @@ -93,10 +104,11 @@ class _MediaCardState extends State { final scope = BrowseScope.of(context); final theme = Theme.of(context); final scheme = theme.colorScheme; + final shown = widget.display ?? item; final selected = scope.selectedItemId == item.id; final played = item.userData.played; final progress = progressOf(item); - final unplayed = item.userData.unplayedItemCount; + final unplayed = shown.userData.unplayedItemCount; final ring = selected ? scheme.primary : _focus @@ -106,20 +118,21 @@ class _MediaCardState extends State { final image = AspectRatio( aspectRatio: widget.shape.aspect, child: AnimatedContainer( - duration: const Duration(milliseconds: 120), + duration: const Duration(milliseconds: 160), + curve: Curves.fastEaseInToSlowEaseOut, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: ring, width: 2), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ring, width: 3), ), child: ClipRRect( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(7), child: Stack( fit: StackFit.expand, children: [ JellyfinImage( client: scope.client, - image: cardImage(item, widget.shape), - icon: kindIcon(item), + image: cardImage(shown, widget.shape), + icon: kindIcon(shown), ), if (_hover || _focus) const ColoredBox(color: Color(0x22FFFFFF)), @@ -141,7 +154,7 @@ class _MediaCardState extends State { ? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary)) : played ? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white)) - : unplayed != null && unplayed > 0 && !isPlayable(item) + : unplayed != null && unplayed > 0 && !isPlayable(shown) && widget.subtitle == null ? _Badge( color: scheme.primaryContainer, child: Text('$unplayed', @@ -151,78 +164,55 @@ class _MediaCardState extends State { color: scheme.onPrimaryContainer))) : const SizedBox.shrink(), ), - if (_hover && isPlayable(item)) - Positioned( - top: 4, - left: 4, - child: IconButton.filledTonal( - key: ValueKey('card-info-${item.id}'), - tooltip: 'Details (I)', - visualDensity: VisualDensity.compact, - iconSize: 16, - onPressed: () => _details(scope), - icon: const Icon(Icons.info_outline), - ), - ), ], ), ), ), ); - final title = cardTitle(item, seriesFirst: widget.seriesFirst); - final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode - ? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ') - : cardSubtitle(item); - return Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (_, e) => _onKey(scope, e), - child: Semantics( - button: true, - selected: selected, - label: title, - child: InkWell( - key: ValueKey('card-${item.id}'), - autofocus: widget.autofocus, - borderRadius: BorderRadius.circular(8), - focusColor: Colors.transparent, - hoverColor: Colors.transparent, - onTap: () => scope.activate(item), - onHover: (h) { - setState(() => _hover = h); - if (h) scope.onPeek(item); - }, - onFocusChange: (f) { - setState(() => _focus = f); - if (f) scope.onPeek(item); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - image, - const SizedBox(height: 6), - SizedBox( - height: cardTextHeight - 6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: selected ? scheme.primary : null)), - Text(sub, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)), - ], - ), + final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst); + final sub = widget.subtitle ?? + (widget.seriesFirst && item.kind == JellyfinItemKind.episode + ? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ') + : cardSubtitle(item)); + return Semantics( + button: true, + selected: selected, + label: title, + child: InkWell( + key: ValueKey('card-${item.id}'), + autofocus: widget.autofocus, + borderRadius: BorderRadius.circular(10), + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + onTap: () => _tap(scope), + onHover: (h) => setState(() => _hover = h), + onFocusChange: (f) => setState(() => _focus = f), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + image, + const SizedBox(height: 6), + SizedBox( + height: cardTextHeight - 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: selected ? scheme.primary : null)), + Text(sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)), + ], ), - ], - ), + ), + ], ), ), ); @@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate { bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth; } -/// A titled horizontal row of cards sized to fill the width; arrows (on -/// hover) page through it. Lazy: only visible cards are built. +/// One card of a [MediaRow]: see [MediaCard]. +typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle}); + +List cardEntries(Iterable items) => + [for (final i in items) (item: i, display: null, subtitle: null)]; + +/// A titled horizontal row of cards sized to fill the width; mouse drag +/// and the wheel move it ([HScroll]), arrows (on hover) page through it. +/// Lazy: only visible cards are built. class MediaRow extends StatefulWidget { final String title; - final List items; + final List entries; final CardShape shape; final bool seriesFirst; final Widget? trailing; const MediaRow({ super.key, required this.title, - required this.items, + required this.entries, required this.shape, this.seriesFirst = false, this.trailing, @@ -315,44 +312,38 @@ class _MediaRowState extends State { @override Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; + final entries = widget.entries; return MouseRegion( onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Text(widget.title, style: t.titleMedium), - const SizedBox(width: 8), - Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)), - const Spacer(), - ?widget.trailing, - ], - ), - ), + SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing), LayoutBuilder(builder: (context, box) { final fit = fitColumns(box.maxWidth, widget.shape.minWidth); final h = fit.itemWidth / widget.shape.aspect + cardTextHeight; - final overflow = widget.items.length > fit.cols; + final overflow = entries.length > fit.cols; return SizedBox( height: h, child: Stack( children: [ - ListView.separated( + HScroll( controller: _scroll, - scrollDirection: Axis.horizontal, - itemCount: widget.items.length, - separatorBuilder: (_, _) => const SizedBox(width: cardGap), - itemBuilder: (context, i) => SizedBox( - width: fit.itemWidth, - child: MediaCard( - item: widget.items[i], - shape: widget.shape, - seriesFirst: widget.seriesFirst, + builder: (c) => ListView.separated( + controller: c, + scrollDirection: Axis.horizontal, + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(width: cardGap), + itemBuilder: (context, i) => SizedBox( + width: fit.itemWidth, + child: MediaCard( + item: entries[i].item, + display: entries[i].display, + subtitle: entries[i].subtitle, + shape: widget.shape, + seriesFirst: widget.seriesFirst, + ), ), ), ), @@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget { padding: const EdgeInsets.only(bottom: 8, top: 4), child: Row( children: [ - Text(title, style: t.titleMedium), - if (count != null) ...[ - const SizedBox(width: 8), - Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)), - ], - const Spacer(), + Expanded( + child: Row( + children: [ + Flexible( + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium), + ), + if (count != null) ...[ + const SizedBox(width: 8), + Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)), + ], + ], + ), + ), ?trailing, ], ), diff --git a/apps/desktop/lib/src/jellyfin/detail_page.dart b/apps/desktop/lib/src/jellyfin/detail_page.dart deleted file mode 100644 index f4231a7..0000000 --- a/apps/desktop/lib/src/jellyfin/detail_page.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart'; - -import 'browse_scope.dart'; -import 'format.dart'; -import 'images.dart'; - -/// Movie / episode detail: art, title, meta, overview, the pick button and -/// what is in the file (video, audio and subtitle tracks). -class JellyfinDetailPage extends StatelessWidget { - final JellyfinItem item; - const JellyfinDetailPage({super.key, required this.item}); - - @override - Widget build(BuildContext context) { - final scope = BrowseScope.of(context); - return Loader( - key: ValueKey('detail-${item.id}-${scope.account.serverId}'), - load: () => scope.load('item:${item.id}', (c) => c.item(item.id)), - builder: (context, full) => _Detail(item: full), - ); - } -} - -class _Detail extends StatelessWidget { - final JellyfinItem item; - const _Detail({required this.item}); - - @override - Widget build(BuildContext context) { - final scope = BrowseScope.of(context); - final theme = Theme.of(context); - final t = theme.textTheme; - final episode = item.kind == JellyfinItemKind.episode; - final selected = scope.selectedItemId == item.id; - final meta = [ - ?item.episodeLabel, - if (item.productionYear != null) '${item.productionYear}', - if (item.runTime != null) formatRuntime(item.runTime!), - ?timeLeft(item), - if (item.officialRating != null) item.officialRating!, - if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}', - if (item.userData.played) 'Watched', - ].join(' · '); - - final art = episode - ? AspectRatio( - aspectRatio: 16 / 9, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: JellyfinImage( - client: scope.client, - image: item.ownImage(JellyfinImageType.primary) ?? - item.image(JellyfinImageType.backdrop), - icon: Icons.tv_outlined), - ), - ) - : AspectRatio( - aspectRatio: 2 / 3, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: JellyfinImage( - client: scope.client, image: item.image(JellyfinImageType.primary)), - ), - ); - - final info = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (episode && item.seriesName != null) - TextButton.icon( - key: const ValueKey('detail-series'), - style: TextButton.styleFrom(padding: EdgeInsets.zero), - onPressed: () => scope.openSeriesOf(item), - icon: const Icon(Icons.live_tv_outlined, size: 18), - label: Text(item.seriesName!, style: t.titleMedium), - ), - SelectableText(item.name, style: t.headlineSmall), - const SizedBox(height: 6), - Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)), - if (progressOf(item) > 0) ...[ - const SizedBox(height: 8), - SizedBox( - width: 240, - child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)), - ], - const SizedBox(height: 16), - FilledButton.icon( - key: const ValueKey('detail-pick'), - onPressed: () => scope.onPick(item), - icon: Icon(selected ? Icons.check : Icons.play_arrow), - label: Text(selected ? 'Picked' : 'Pick to watch'), - ), - if (item.overview != null) ...[ - const SizedBox(height: 16), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: SelectableText(item.overview!, style: t.bodyMedium), - ), - ], - const SizedBox(height: 16), - _Tracks(item: item), - ], - ); - - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 24), - child: LayoutBuilder(builder: (context, box) { - final wide = box.maxWidth >= 760; - final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0; - if (!wide) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.topLeft, - child: SizedBox(width: episode ? 320 : 180, child: art)), - const SizedBox(height: 16), - info, - ], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: artWidth, child: art), - const SizedBox(width: 24), - Expanded(child: info), - ], - ); - }), - ); - } -} - -/// What the file holds, one line per track kind. -class _Tracks extends StatelessWidget { - final JellyfinItem item; - const _Tracks({required this.item}); - - @override - Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; - final source = item.mediaSources.firstOrNull; - final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams; - if (streams.isEmpty) return const SizedBox.shrink(); - String names(JellyfinStreamType type) => streams - .where((s) => s.type == type) - .map((s) => s.displayTitle ?? [s.language, s.codec].whereType().join(' ')) - .join(' · '); - final rows = [ - ('Video', names(JellyfinStreamType.video)), - ('Audio', names(JellyfinStreamType.audio)), - ('Subtitles', names(JellyfinStreamType.subtitle)), - if (source?.container != null || source?.size != null) - ('File', [ - ?source?.container, - if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB', - ].join(' · ')), - ].where((r) => r.$2.isNotEmpty); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final (label, value) in rows) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 84, - child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))), - Expanded(child: Text(value, style: t.bodySmall)), - ], - ), - ), - ], - ); - } -} diff --git a/apps/desktop/lib/src/jellyfin/details_panel.dart b/apps/desktop/lib/src/jellyfin/details_panel.dart new file mode 100644 index 0000000..70a5efc --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/details_panel.dart @@ -0,0 +1,520 @@ +import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../shell/motion.dart'; +import 'accounts.dart'; +import 'browse_scope.dart'; +import 'cards.dart' show doubleClickTime, kindIcon; +import 'format.dart'; +import 'home_rows.dart' show continueKey; +import 'images.dart'; + +/// Width of the details panel (docked or floating). +const detailsPanelWidth = 400.0; + +/// What putting up [item] plays: itself when playable; for a series its +/// next episode (resume point or next up), else its first unwatched, else +/// its first episode. Null when there is nothing to play. +Future playableFor(JellyfinClient c, JellyfinItem item) async { + if (isPlayable(item)) return item; + if (item.kind != JellyfinItemKind.series) return null; + final next = await c.nextUp(seriesId: item.id, limit: 1, enableResumable: true); + if (next.isNotEmpty) return next.first; + final eps = await c.episodes(item.id); + return eps.where((e) => !e.userData.played).firstOrNull ?? eps.firstOrNull; +} + +typedef _Data = ({ + JellyfinItem item, + JellyfinItem? series, + List seasons, + JellyfinItem? next, +}); + +/// The selected card's details, at the right of the library: banner, +/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint] +/// offline), mark watched / unwatched, remove from Continue, overview and, +/// for a series or an episode, the episode list (click picks which +/// episode goes up, double-click puts it up). Reloads when the +/// [controller]'s data version moves. +class JellyfinDetailsPanel extends StatefulWidget { + final BrowseController controller; + final JellyfinSelection selection; + /// Null when online; else why nothing can go up. + final String? offlineHint; + /// Puts [item] (playable) up; returns an error text, or null when sent. + final Future Function(JellyfinSelection item) onPutUp; + final VoidCallback onClose; + const JellyfinDetailsPanel({ + super.key, + required this.controller, + required this.selection, + required this.onPutUp, + required this.onClose, + this.offlineHint, + }); + + @override + State createState() => _JellyfinDetailsPanelState(); +} + +class _JellyfinDetailsPanelState extends State { + String? _seasonId; + String? _targetId; + bool _busy = false; + String? _error; + DateTime? _lastTap; + String? _lastTapId; + + BrowseController get _c => widget.controller; + JellyfinAccounts get _accounts => _c.accounts; + String get _serverId => widget.selection.serverId; + JellyfinClient? get _client => _accounts.clientFor(_serverId); + + Future _cached(String key, Future Function(JellyfinClient c) fn) { + final c = _client; + if (c == null) return Future.error(const JellyfinException('that Jellyfin server is not linked any more')); + return _accounts.cached(_serverId, key, () => fn(c)); + } + + Future<_Data> _load() async { + await _accounts.ensureLoaded(); + final id = widget.selection.itemId; + final item = await _cached('item:$id', (c) => c.item(id)); + final sid = item.kind == JellyfinItemKind.series ? item.id : item.seriesId; + if (sid == null || (item.kind != JellyfinItemKind.series && item.kind != JellyfinItemKind.episode)) { + return (item: item, series: null, seasons: const [], next: null); + } + final r = await Future.wait([ + item.kind == JellyfinItemKind.series ? Future.value(item) : _cached('item:$sid', (c) => c.item(sid)), + _cached('seasons:$sid', (c) => c.seasons(sid)), + _cached('next:$sid', (c) => c.nextUp(seriesId: sid, limit: 1, enableResumable: true)) + .then?>((v) => v, onError: (_) => null), + ]); + final next = r[2] as List?; + return ( + item: item, + series: r[0] as JellyfinItem, + seasons: r[1] as List, + next: next == null || next.isEmpty ? null : next.first, + ); + } + + Future> _episodes(String seriesId, String seasonId) => + _cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId)); + + Future _putUp(JellyfinItem target) async { + if (_busy || widget.offlineHint != null) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + error = await widget.onPutUp(JellyfinSelection(_serverId, target.id, target)); + } catch (e) { + error = '$e'; + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + } + + /// Runs a watched-state change, then lets every row reload. + Future _change(Future Function(JellyfinClient c) action) async { + final c = _client; + if (c == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + await action(c); + } on JellyfinException catch (e) { + if (e.isUnauthorized) _accounts.markExpired(_serverId); + error = e.isUnauthorized ? 'Jellyfin sign-in expired' : e.message; + } catch (e) { + error = '$e'; + } + _c.dataChanged(); + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + } + + void _toggleWatched(JellyfinItem t) => + _change((c) => t.userData.played ? c.markUnplayed(t.id) : c.markPlayed(t.id)); + + void _removeFromContinue(JellyfinItem t) => _change((c) async { + if (t.userData.isResumable) await c.clearResumePosition(t.id); + await _accounts.dismissContinue(_serverId, continueKey(t)); + }); + + void _tapEpisode(JellyfinItem e) { + final now = DateTime.now(); + final again = _lastTapId == e.id && _lastTap != null && now.difference(_lastTap!) < doubleClickTime; + _lastTap = again ? null : now; + _lastTapId = e.id; + setState(() => _targetId = e.id); + if (again) _putUp(e); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + key: const ValueKey('jf-details'), + color: scheme.surfaceContainerLow, + child: SizedBox( + width: detailsPanelWidth, + child: ListenableBuilder( + listenable: _c, + builder: (context, _) => Loader<_Data>( + key: ValueKey('details-${widget.selection.serverId}-${widget.selection.itemId}'), + version: _c.version, + load: _load, + loading: Stack(children: [ + const Center(child: CircularProgressIndicator(strokeWidth: 2)), + Positioned(right: 4, top: 4, child: _closeButton()), + ]), + builder: (context, d) => _body(context, d), + ), + ), + ), + ); + } + + Widget _closeButton() => IconButton( + key: const ValueKey('details-close'), + tooltip: 'Close', + onPressed: widget.onClose, + icon: const Icon(Icons.close), + ); + + Widget _body(BuildContext context, _Data d) { + final series = d.series; + if (series == null) return _view(context, d, null, null); + final preferred = d.item.kind == JellyfinItemKind.episode ? d.item : null; + final baseTarget = preferred ?? d.next; + final seasons = d.seasons; + final seasonId = (_seasonId != null && seasons.any((s) => s.id == _seasonId)) + ? _seasonId + : seasons.any((s) => s.id == baseTarget?.seasonId) + ? baseTarget!.seasonId + : (seasons.where((s) => !s.userData.played).firstOrNull ?? seasons.firstOrNull)?.id; + if (seasonId == null) return _view(context, d, baseTarget, const []); + return Loader>( + key: ValueKey('details-episodes-$seasonId'), + version: _c.version, + load: () => _episodes(series.id, seasonId), + loading: _view(context, d, baseTarget, null), + builder: (context, eps) { + final chosen = _targetId == null + ? null + : eps.where((e) => e.id == _targetId).firstOrNull ?? + (baseTarget?.id == _targetId ? baseTarget : null); + final fresh = baseTarget == null ? null : eps.where((e) => e.id == baseTarget.id).firstOrNull; + final target = chosen ?? + fresh ?? + baseTarget ?? + eps.where((e) => !e.userData.played).firstOrNull ?? + eps.firstOrNull; + return _view(context, d, target, eps, seasonId: seasonId); + }, + ); + } + + /// [episodes] null = loading, empty = none (or not a series). + Widget _view(BuildContext context, _Data d, JellyfinItem? target, List? episodes, + {String? seasonId}) { + final theme = Theme.of(context); + final t = theme.textTheme; + final scheme = theme.colorScheme; + final client = _client; + final shown = d.series ?? d.item; + final isSeries = d.series != null; + final playable = isSeries ? target : (isPlayable(d.item) ? d.item : null); + final banner = shown.image(JellyfinImageType.backdrop) ?? + shown.image(JellyfinImageType.thumb) ?? + (d.item.kind == JellyfinItemKind.episode ? d.item.ownImage(JellyfinImageType.primary) : null) ?? + shown.image(JellyfinImageType.primary); + final eps = shown.recursiveItemCount; + final meta = [ + if (shown.productionYear != null) '${shown.productionYear}', + if (isSeries && d.seasons.length > 1) '${d.seasons.length} seasons', + if (isSeries && eps != null && eps > 0) '$eps episodes', + if (!isSeries && shown.runTime != null) formatRuntime(shown.runTime!), + ?shown.officialRating, + if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}', + if (!isSeries && shown.userData.played) 'Watched', + ].join(' · '); + final hint = widget.offlineHint; + final label = playable == null + ? (hint == null ? 'Put it up' : 'Play') + : hint != null + ? 'Play' + : isSeries + ? 'Put it up · ${playable.episodeLabel ?? playable.name}' + : 'Put it up'; + final inContinue = playable != null && + (playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable))); + final left = playable == null ? null : timeLeft(playable); + + return ListView( + key: const ValueKey('details-list'), + padding: EdgeInsets.zero, + children: [ + SizedBox( + height: 180, + child: Stack( + fit: StackFit.expand, + children: [ + if (client != null) + JellyfinImage(client: client, image: banner, alignment: Alignment.topCenter, icon: kindIcon(shown)) + else + ColoredBox(color: scheme.surfaceContainerHighest), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, scheme.surfaceContainerLow], + stops: const [0.55, 1], + ), + ), + ), + Positioned(right: 4, top: 4, child: _closeButton()), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(shown.name, style: t.titleLarge), + if (meta.isNotEmpty) Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)), + const SizedBox(height: 12), + Wrap( + spacing: 4, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + key: const ValueKey('details-put-up'), + onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable), + icon: _busy + ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow), + label: Text(label, overflow: TextOverflow.ellipsis), + ), + if (playable != null) + IconButton( + key: const ValueKey('details-watched'), + onPressed: _busy ? null : () => _toggleWatched(playable), + tooltip: playable.userData.played ? 'Mark unwatched' : 'Mark watched', + icon: Icon(playable.userData.played ? Icons.check_circle : Icons.check_circle_outline), + ), + if (inContinue) + IconButton( + key: const ValueKey('details-remove-continue'), + onPressed: _busy ? null : () => _removeFromContinue(playable), + tooltip: 'Remove from Continue', + icon: const Icon(Icons.remove_circle_outline), + ), + if (isSeries) + IconButton( + key: const ValueKey('details-open-series'), + tooltip: 'Open the series page', + onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)), + icon: const Icon(Icons.open_in_new), + ), + ], + ), + if (hint != null || _error != null) ...[ + const SizedBox(height: 6), + Text( + _error ?? hint!, + key: const ValueKey('details-hint'), + style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), + ), + ], + if (isSeries && playable != null) ...[ + const SizedBox(height: 10), + Text( + [playable.episodeLabel ?? '', playable.name].where((s) => s.isNotEmpty).join(' · '), + style: t.titleSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (left != null) ...[ + const SizedBox(height: 6), + Row(children: [ + SizedBox(width: 160, child: LinearProgressIndicator(value: progressOf(playable!), minHeight: 3)), + const SizedBox(width: 8), + Text(left, style: t.bodySmall?.copyWith(color: Colors.grey)), + ]), + ], + if (shown.overview != null) ...[ + const SizedBox(height: 12), + Text(shown.overview!, style: t.bodyMedium), + ], + if (!isSeries) ...[ + const SizedBox(height: 12), + _Tracks(item: d.item), + ], + if (isSeries) ...[ + const SizedBox(height: 16), + _episodesHeader(context, d, seasonId), + if (episodes == null) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ) + else if (episodes.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('No episodes', style: TextStyle(color: Colors.grey)), + ) + else + for (final e in episodes) _episodeRow(context, e, target?.id == e.id), + ], + ], + ), + ), + ], + ); + } + + Widget _episodesHeader(BuildContext context, _Data d, String? seasonId) { + final seasons = d.seasons; + return Row( + children: [ + const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))), + if (seasons.length > 1) + DropdownButton( + key: const ValueKey('details-season'), + value: seasonId, + isDense: true, + underline: const SizedBox.shrink(), + borderRadius: BorderRadius.circular(10), + items: [ + for (final s in seasons) + DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)), + ], + onChanged: (id) => setState(() => _seasonId = id), + ), + ], + ); + } + + Widget _episodeRow(BuildContext context, JellyfinItem e, bool isTarget) { + final scheme = Theme.of(context).colorScheme; + final t = Theme.of(context).textTheme; + final client = _client; + final sub = e.userData.played ? 'watched' : timeLeft(e) ?? (e.runTime == null ? '' : formatRuntime(e.runTime!)); + return Material( + color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + key: ValueKey('details-episode-${e.id}'), + borderRadius: BorderRadius.circular(8), + onTap: () => _tapEpisode(e), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 64, + height: 36, + child: client == null + ? ColoredBox(color: scheme.surfaceContainerHighest) + : JellyfinImage( + client: client, + image: e.ownImage(JellyfinImageType.primary) ?? e.image(JellyfinImageType.thumb), + icon: Icons.tv_outlined, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.indexNumber == null ? e.name : 'E${e.indexNumber} · ${e.name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.bodyMedium?.copyWith( + color: isTarget ? scheme.primary : e.userData.played ? Colors.grey : null, + fontWeight: isTarget ? FontWeight.w600 : null, + ), + ), + if (sub.isNotEmpty) Text(sub, style: t.bodySmall?.copyWith(color: Colors.grey)), + ], + ), + ), + AnimatedOpacity( + opacity: isTarget ? 1 : 0, + duration: easeTime, + child: Icon(Icons.play_circle, color: scheme.primary), + ), + ], + ), + ), + ), + ); + } +} + +/// What the file holds, one line per track kind. +class _Tracks extends StatelessWidget { + final JellyfinItem item; + const _Tracks({required this.item}); + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final source = item.mediaSources.firstOrNull; + final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams; + if (streams.isEmpty) return const SizedBox.shrink(); + String names(JellyfinStreamType type) => streams + .where((s) => s.type == type) + .map((s) => s.displayTitle ?? [s.language, s.codec].whereType().join(' ')) + .join(' · '); + final rows = [ + ('Video', names(JellyfinStreamType.video)), + ('Audio', names(JellyfinStreamType.audio)), + ('Subtitles', names(JellyfinStreamType.subtitle)), + if (source?.container != null || source?.size != null) + ('File', [ + ?source?.container, + if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB', + ].join(' · ')), + ].where((r) => r.$2.isNotEmpty); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (label, value) in rows) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 76, child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))), + Expanded(child: Text(value, style: t.bodySmall)), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/format.dart b/apps/desktop/lib/src/jellyfin/format.dart index 8ebb182..147aac0 100644 --- a/apps/desktop/lib/src/jellyfin/format.dart +++ b/apps/desktop/lib/src/jellyfin/format.dart @@ -33,6 +33,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) { _ => !item.isFolder && item.mediaType == 'Video', }; +/// Cards that select (details panel) on click: playable items and series. +bool isSelectable(JellyfinItem item) => isPlayable(item) || item.kind == JellyfinItemKind.series; + /// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ... String cardSubtitle(JellyfinItem item, {bool withSeries = false}) { final parts = []; diff --git a/apps/desktop/lib/src/jellyfin/home_page.dart b/apps/desktop/lib/src/jellyfin/home_page.dart index 31feac0..f17a0c1 100644 --- a/apps/desktop/lib/src/jellyfin/home_page.dart +++ b/apps/desktop/lib/src/jellyfin/home_page.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../state/settings.dart'; import 'browse_scope.dart'; import 'cards.dart'; +import 'home_rows.dart'; /// Libraries that hold videos; music, books, photos and playlists are left /// out of the browser. @@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) { _ => Icons.folder_outlined, }; -/// Start page: library shortcuts, then Continue watching, Next up and the -/// latest additions of each library. Each row loads on its own, so a slow -/// library does not hold up the rest. +/// The video libraries of the scope's server (cached). +Future> videoLibraries(BrowseScope scope) => + scope.load('views', (c) => c.userViews()).then((all) => all.where(isVideoLibrary).toList()); + +/// Start page: (library shortcuts,) one Continue row (resume points and +/// next up merged per series, see [mergeContinue]), then the newest +/// additions of each library as series cards ([latestCards]). Each row +/// loads on its own, so a slow library does not hold up the rest; rows +/// reload when watched state changes ([BrowseController.dataChanged]). class JellyfinHomePage extends StatelessWidget { const JellyfinHomePage({super.key}); @@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget { final scope = BrowseScope.of(context); return Loader>( key: ValueKey('views-${scope.account.serverId}'), - load: () => scope.load('views', (c) => c.userViews()), - builder: (context, all) { - final views = all.where(isVideoLibrary).toList(); - return ListView( - key: const PageStorageKey('jf-home'), - padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), - children: [ - _LibraryStrip(views: views), - const SizedBox(height: 20), - _Section( - key: const ValueKey('row-resume'), - title: 'Continue watching', - shape: CardShape.thumb, - seriesFirst: true, - load: () => scope.load('resume', (c) => c.resume(limit: 30)), - ), - _Section( - key: const ValueKey('row-nextup'), - title: 'Next up', - shape: CardShape.thumb, - seriesFirst: true, - load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)), - ), - for (final v in views) - _Section( - key: ValueKey('row-latest-${v.id}'), - title: 'Latest in ${v.name}', - shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster, - load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)), - trailing: TextButton( - onPressed: () => scope.onOpen(LibraryRoute(v)), - child: const Text('Show all'), - ), - ), - ], - ); - }, + load: () => videoLibraries(scope), + builder: (context, views) => ListenableBuilder( + listenable: scope.controller, + builder: (context, _) { + final v = scope.controller.version; + return ListView( + key: const PageStorageKey('jf-home'), + padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), + children: [ + if (scope.libraryStrip) ...[ + LibraryStrip(views: views), + const SizedBox(height: 20), + ] else if (views.isEmpty) + const _NoLibraries(), + _ContinueRow(version: v), + for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v), + ], + ); + }, + ), ); } } -class _LibraryStrip extends StatelessWidget { +class _NoLibraries extends StatelessWidget { + const _NoLibraries(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.only(bottom: 20), + child: Text('No video libraries on this server for your user.', style: TextStyle(color: Colors.grey)), + ); +} + +/// The libraries as chips; a click opens one. +class LibraryStrip extends StatelessWidget { final List views; - const _LibraryStrip({required this.views}); + const LibraryStrip({super.key, required this.views}); @override Widget build(BuildContext context) { final scope = BrowseScope.of(context); - if (views.isEmpty) { - return const Text('No video libraries on this server for your user.', - style: TextStyle(color: Colors.grey)); - } + if (views.isEmpty) return const _NoLibraries(); return Wrap( spacing: 8, runSpacing: 8, @@ -92,46 +92,116 @@ class _LibraryStrip extends StatelessWidget { avatar: Icon(libraryIcon(v), size: 18), label: Text(v.name), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - onPressed: () => scope.onOpen(LibraryRoute(v)), + onPressed: () => scope.controller.openTop(LibraryRoute(v)), ), ], ); } } -class _Section extends StatelessWidget { - final String title; - final CardShape shape; - final bool seriesFirst; - final Future> Function() load; - final Widget? trailing; - const _Section({ - super.key, - required this.title, - required this.shape, - required this.load, - this.seriesFirst = false, - this.trailing, - }); +class _ContinueRow extends StatelessWidget { + final int version; + const _ContinueRow({required this.version}); + + static const title = 'Continue'; + + Future> _load(BrowseScope scope) async { + final lists = await Future.wait([ + scope.load('resume', (c) => c.resume(limit: 40)), + scope.load('nextup', (c) => c.nextUp(limit: 40)), + // Only for ordering: a failure leaves the row as it is. + scope + .load('played', (c) => c.recentlyPlayed(limit: 150)) + .catchError((Object _) => const []), + ]); + return mergeContinue( + resume: lists[0], + nextUp: lists[1], + recentlyPlayed: lists[2], + dismissed: scope.accounts.continueDismissed(scope.account.serverId), + now: scope.controller.now(), + ); + } @override Widget build(BuildContext context) { + final scope = BrowseScope.of(context); return Loader>( - load: load, + version: version, + load: () => _load(scope), + showError: false, + loading: const _RowSkeleton(title: title, shape: CardShape.thumb), + builder: (context, items) { + scope.controller.continueKeys = {for (final i in items) continueKey(i)}; + if (items.isEmpty) return const SizedBox.shrink(); + return Padding( + key: const ValueKey('row-continue'), + padding: const EdgeInsets.only(bottom: 24), + child: MediaRow( + title: title, + entries: cardEntries(items), + shape: CardShape.thumb, + seriesFirst: true, + ), + ); + }, + ); + } +} + +class _LatestRow extends StatelessWidget { + final JellyfinItem view; + final int version; + const _LatestRow({super.key, required this.view, required this.version}); + + @override + Widget build(BuildContext context) { + final scope = BrowseScope.of(context); + final title = 'Latest in ${view.name}'; + final shape = view.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster; + return Loader>( + version: version, + load: () => scope.load('latest:${view.id}', (c) => c.latest(view.id, limit: 100, groupItems: false)), showError: false, loading: _RowSkeleton(title: title, shape: shape), - builder: (context, items) => items.isEmpty - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(bottom: 24), - child: MediaRow( - title: title, - items: items, - shape: shape, - seriesFirst: seriesFirst, - trailing: trailing, - ), + builder: (context, items) => SignalBuilder(builder: (context) { + final hide = AppSettings.instance.hideReReleases.value; + final cards = latestCards(items, hideReReleases: hide); + if (cards.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: MediaRow( + key: ValueKey('latest-${view.id}'), + title: title, + entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)], + shape: shape, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + key: ValueKey('show-all-${view.id}'), + // Newest first, like the row. + onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')), + child: const Text('Show all'), + ), + PopupMenuButton( + key: ValueKey('latest-menu-${view.id}'), + tooltip: 'Row options', + icon: const Icon(Icons.more_vert, size: 18), + itemBuilder: (context) => [ + CheckedPopupMenuItem( + key: const ValueKey('hide-rereleases'), + checked: hide, + onTap: () => AppSettings.instance.setHideReReleases(!hide), + child: const Text('Hide re-releases in Latest'), + ), + ], + ), + ], ), + ), + ); + }), ); } } diff --git a/apps/desktop/lib/src/jellyfin/home_rows.dart b/apps/desktop/lib/src/jellyfin/home_rows.dart new file mode 100644 index 0000000..053d4ba --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/home_rows.dart @@ -0,0 +1,163 @@ +import 'package:sync_relay_core/core.dart'; + +import 'format.dart'; + +// What the Jellyfin home rows show, as pure functions over the API lists +// (tested without widgets). + +/// Series not watched for longer than this leave the Continue row, unless +/// nothing newer is left. +const continueMaxAge = Duration(days: 60); + +/// Added this long after it first aired: a re-release ([isReRelease]). +const reReleaseAge = Duration(days: 30); + +/// One Continue card per series: an episode's series, else the item itself. +String continueKey(JellyfinItem item) => item.seriesId ?? item.id; + +DateTime? _later(DateTime? a, DateTime? b) => + a == null ? b : b == null || !b.isAfter(a) ? a : b; + +/// The Continue row: Continue watching ([resume]) and Next up ([nextUp]) +/// merged into one card per series, the series watched most recently first. +/// A series shows its resume item when that was the last thing played in +/// it, else its next-up episode. When a series was last played comes from +/// the resume points and from [recentlyPlayed] (finished episodes, which +/// next-up items do not carry). Series untouched for [maxAge] are left out +/// unless nothing else is left; [dismissed] (key -> when) hides a series +/// until it is played again after that. +List mergeContinue({ + required List resume, + required List nextUp, + List recentlyPlayed = const [], + Map dismissed = const {}, + required DateTime now, + Duration maxAge = continueMaxAge, +}) { + final finished = {}; + for (final p in recentlyPlayed) { + final at = p.userData.lastPlayedDate; + if (at != null) finished[continueKey(p)] = _later(finished[continueKey(p)], at)!; + } + final resumeBy = {}; + final nextBy = {}; + final order = []; + for (final r in resume) { + final k = continueKey(r); + final had = resumeBy[k]; + if (had == null) order.add(k); + final at = r.userData.lastPlayedDate; + if (had == null || (at != null && at.isAfter(had.userData.lastPlayedDate ?? DateTime(0)))) { + resumeBy[k] = r; + } + } + for (final n in nextUp) { + final k = continueKey(n); + if (nextBy.containsKey(k)) continue; + nextBy[k] = n; + if (!resumeBy.containsKey(k)) order.add(k); + } + + final cards = <({JellyfinItem item, DateTime? at, int index})>[]; + for (final (i, k) in order.indexed) { + final r = resumeBy[k], n = nextBy[k], done = finished[k]; + final resumedAt = r?.userData.lastPlayedDate; + final JellyfinItem item; + if (r != null && (n == null || (resumedAt != null && (done == null || !resumedAt.isBefore(done))))) { + item = r; + } else { + item = n!; + } + final at = _later(resumedAt, done); + final hiddenAt = dismissed[k]; + if (hiddenAt != null && (at == null || !at.isAfter(hiddenAt))) continue; + cards.add((item: item, at: at, index: i)); + } + cards.sort((a, b) { + if (a.at == null || b.at == null) { + return a.at == b.at ? a.index.compareTo(b.index) : (a.at == null ? 1 : -1); + } + final c = b.at!.compareTo(a.at!); + return c != 0 ? c : a.index.compareTo(b.index); + }); + final fresh = [for (final c in cards) if (c.at != null && now.difference(c.at!) <= maxAge) c.item]; + return fresh.isNotEmpty ? fresh : [for (final c in cards) c.item]; +} + +/// Added to the library long ([age]) after it first aired: a re-upload or +/// an old season added late, not news. Unknown dates are never re-releases. +bool isReRelease(JellyfinItem item, {Duration age = reReleaseAge}) { + final aired = item.premiereDate, added = item.dateCreated; + return aired != null && added != null && added.difference(aired) > age; +} + +/// One card of a "Latest in X" row: [item] is what selecting or putting it +/// up acts on, [display] gives the picture and title, [subtitle] the line +/// under it. +class LatestCard { + final JellyfinItem item; + final JellyfinItem display; + final String subtitle; + const LatestCard(this.item, this.display, this.subtitle); +} + +/// A series to show for [episode]: its id, name and poster. +JellyfinItem seriesOf(JellyfinItem episode) => JellyfinItem( + id: episode.seriesId!, + name: episode.seriesName ?? episode.name, + type: JellyfinItemType.series, + isFolder: true, + imageTags: { + if (episode.seriesPrimaryImageTag != null) + JellyfinImageType.primary.apiName: episode.seriesPrimaryImageTag!, + }, + parentBackdropItemId: episode.parentBackdropItemId, + parentBackdropImageTags: episode.parentBackdropImageTags, + ); + +/// "Latest in X" from the library's newest items (ungrouped, newest +/// first): episodes become one card per series (its poster and name, +/// "S1E1" for a single new episode, else "N new"), in order of the newest +/// episode; movies and other items stay as they are. With [hideReReleases] +/// items that [isReRelease] are left out first. +List latestCards(List items, {bool hideReReleases = true}) { + final kept = hideReReleases ? items.where((i) => !isReRelease(i)) : items; + final groups = >{}; + final out = []; // a JellyfinItem, or a series id (group) + for (final i in kept) { + final sid = i.seriesId; + if (i.kind == JellyfinItemKind.episode && sid != null) { + final g = groups[sid]; + if (g == null) { + groups[sid] = [i]; + out.add(sid); + } else { + g.add(i); + } + } else { + out.add(i); + } + } + return [ + for (final o in out) + if (o is JellyfinItem) + LatestCard( + o, + o, + o.kind == JellyfinItemKind.series && (o.childCount ?? 0) > 1 + ? '${o.childCount} new' + : cardSubtitle(o), + ) + else + _seriesCard(groups[o]!), + ]; +} + +LatestCard _seriesCard(List eps) { + final series = seriesOf(eps.first); + if (eps.length == 1) { + final e = eps.single; + return LatestCard(e, series, e.episodeLabel ?? e.name); + } + return LatestCard(series, series, '${eps.length} new'); +} diff --git a/apps/desktop/lib/src/jellyfin/inspector.dart b/apps/desktop/lib/src/jellyfin/inspector.dart deleted file mode 100644 index 04e361c..0000000 --- a/apps/desktop/lib/src/jellyfin/inspector.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart'; - -import 'browse_scope.dart'; -import 'format.dart'; -import 'images.dart'; - -/// Side pane on wide windows: details of whatever card is hovered or -/// focused, so browsing needs no extra clicks. Pick / Open act on it. -class JellyfinInspector extends StatelessWidget { - final ValueListenable item; - const JellyfinInspector({super.key, required this.item}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( - key: const ValueKey('jf-inspector'), - decoration: BoxDecoration( - color: scheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)), - ), - child: ValueListenableBuilder( - valueListenable: item, - builder: (context, it, _) => it == null - ? const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text('Hover or focus a title to see its details here.', - textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)), - ), - ) - : _Body(item: it), - ), - ); - } -} - -class _Body extends StatelessWidget { - final JellyfinItem item; - const _Body({required this.item}); - - @override - Widget build(BuildContext context) { - final scope = BrowseScope.of(context); - final t = Theme.of(context).textTheme; - final playable = isPlayable(item); - final selected = scope.selectedItemId == item.id; - final episode = item.kind == JellyfinItemKind.episode; - final art = episode - ? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop) - : item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb); - final meta = [ - if (item.productionYear != null) '${item.productionYear}', - if (playable && item.runTime != null) formatRuntime(item.runTime!), - if (item.officialRating != null) item.officialRating!, - if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}', - if (!playable && (item.userData.unplayedItemCount ?? 0) > 0) - '${item.userData.unplayedItemCount} unwatched', - if (item.userData.played) 'Watched', - ].join(' · '); - return ListView( - padding: EdgeInsets.zero, - children: [ - ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: AspectRatio( - aspectRatio: 16 / 9, - child: JellyfinImage(client: scope.client, image: art), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (episode && item.seriesName != null) - Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)), - Text( - episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name, - style: t.titleMedium, - ), - const SizedBox(height: 4), - Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)), - if (timeLeft(item) != null) ...[ - const SizedBox(height: 8), - LinearProgressIndicator(value: progressOf(item), minHeight: 3), - const SizedBox(height: 4), - Text(timeLeft(item)!, style: t.bodySmall), - ], - const SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: [ - if (playable) - FilledButton.icon( - onPressed: () => scope.onPick(item), - icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18), - label: Text(selected ? 'Picked' : 'Pick'), - ) - else - FilledButton.tonalIcon( - onPressed: () => scope.activate(item), - icon: const Icon(Icons.arrow_forward, size: 18), - label: const Text('Open'), - ), - if (playable) - OutlinedButton( - onPressed: () => scope.onOpen(DetailRoute(item)), - child: const Text('Details'), - ), - if (episode && item.seriesId != null) - OutlinedButton( - onPressed: () => scope.openSeriesOf(item), - child: const Text('Series'), - ), - ]), - if (item.overview != null) ...[ - const SizedBox(height: 12), - Text(item.overview!, style: t.bodyMedium), - ], - ], - ), - ), - ], - ); - } -} diff --git a/apps/desktop/lib/src/jellyfin/library_page.dart b/apps/desktop/lib/src/jellyfin/library_page.dart index 8ac7aaa..67ae965 100644 --- a/apps/desktop/lib/src/jellyfin/library_page.dart +++ b/apps/desktop/lib/src/jellyfin/library_page.dart @@ -39,15 +39,18 @@ const libraryPageSize = 100; /// pages load as they come near the viewport. class JellyfinLibraryPage extends StatefulWidget { final JellyfinItem folder; - const JellyfinLibraryPage({super.key, required this.folder}); + /// A [LibrarySort] name to start with (in its natural direction). + final String? sort; + const JellyfinLibraryPage({super.key, required this.folder, this.sort}); @override State createState() => _JellyfinLibraryPageState(); } class _JellyfinLibraryPageState extends State { - LibrarySort _sort = LibrarySort.name; - bool _descending = false; + late LibrarySort _sort = + LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name; + late bool _descending = _sort.descending; bool _unplayed = false; bool _favorites = false; diff --git a/apps/desktop/lib/src/jellyfin/picker_source.dart b/apps/desktop/lib/src/jellyfin/picker_source.dart index ff6b3cb..c144e20 100644 --- a/apps/desktop/lib/src/jellyfin/picker_source.dart +++ b/apps/desktop/lib/src/jellyfin/picker_source.dart @@ -1,31 +1,32 @@ import 'package:flutter/material.dart'; -import '../screens/home/picked_media.dart'; import '../screens/home/ui.dart'; import 'accounts.dart'; import 'browse_scope.dart'; import 'browser.dart'; import 'sign_in_panel.dart'; -/// The media picker's Jellyfin source: the library browser once a server -/// is linked, else an inline add-server panel. +/// The Library's Jellyfin source: the library browser once a server is +/// linked, else an inline add-server panel. class JellyfinPickerSource extends StatefulWidget { final JellyfinAccounts accounts; - final JellyfinMedia? selected; - final ValueChanged onPick; + final BrowseController? controller; + final JellyfinSelection? selected; + final ValueChanged onSelect; + final ValueChanged onPutUp; final VoidCallback? onOpenIntegrations; - final VoidCallback? onToggleExpanded; - final bool expanded; final BrowseStart? start; + final bool libraryStrip; const JellyfinPickerSource({ super.key, this.start, required this.accounts, - required this.onPick, + required this.onSelect, + required this.onPutUp, + this.controller, this.selected, this.onOpenIntegrations, - this.onToggleExpanded, - this.expanded = false, + this.libraryStrip = true, }); @override @@ -52,12 +53,13 @@ class _JellyfinPickerSourceState extends State { return JellyfinBrowser( key: const ValueKey('media-jellyfin'), accounts: a, + controller: widget.controller, selected: widget.selected, - onPick: widget.onPick, + onSelect: widget.onSelect, + onPutUp: widget.onPutUp, onOpenIntegrations: widget.onOpenIntegrations, - onToggleExpanded: widget.onToggleExpanded, - expanded: widget.expanded, start: widget.start, + libraryStrip: widget.libraryStrip, ); }, ); diff --git a/apps/desktop/lib/src/jellyfin/series_page.dart b/apps/desktop/lib/src/jellyfin/series_page.dart index 44364bf..49711e2 100644 --- a/apps/desktop/lib/src/jellyfin/series_page.dart +++ b/apps/desktop/lib/src/jellyfin/series_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:sync_relay_core/core.dart'; import 'browse_scope.dart'; +import 'cards.dart' show doubleClickTime; import 'format.dart'; import 'images.dart'; @@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State { final scope = BrowseScope.of(context); return Loader<_SeriesData>( key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'), + version: scope.controller.version, load: () => _load(scope), builder: (context, d) { final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId)) @@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State { key: ValueKey('episodes-$seasonId'), seriesId: widget.series.id, seasonId: seasonId, + version: scope.controller.version, ), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], @@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> { Wrap(spacing: 8, runSpacing: 8, children: [ FilledButton.icon( key: const ValueKey('series-next'), - onPressed: next == null ? null : () => scope.onPick(next), + onPressed: next == null ? null : () => scope.onSelect(next), icon: Icon(selectedNext ? Icons.check : Icons.play_arrow), label: Text(nextLabel, overflow: TextOverflow.ellipsis), ), @@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> { class _EpisodeList extends StatefulWidget { final String seriesId; final String seasonId; - const _EpisodeList({super.key, required this.seriesId, required this.seasonId}); + final int version; + const _EpisodeList( + {super.key, required this.seriesId, required this.seasonId, required this.version}); @override State<_EpisodeList> createState() => _EpisodeListState(); @@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget { class _EpisodeListState extends State<_EpisodeList> { Future>? _future; + List? _last; + + @override + void didUpdateWidget(_EpisodeList old) { + super.didUpdateWidget(old); + if (old.version != widget.version) _future = null; + } void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}', (c) => c.episodes(widget.seriesId, seasonId: widget.seasonId)); @@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> { return SliverToBoxAdapter( child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope)))); } - final eps = snap.data; + final eps = snap.data ?? _last; + if (snap.data != null) _last = snap.data; if (eps == null) { return const SliverToBoxAdapter( child: Padding( @@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> { const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2; /// One episode: thumbnail with progress, number + title, runtime / air -/// date, two lines of overview. Click picks it; I opens details. +/// date, two lines of overview. Click selects it (details panel), a second +/// click soon after puts it up. class EpisodeTile extends StatefulWidget { final JellyfinItem episode; const EpisodeTile({super.key, required this.episode}); @@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget { class _EpisodeTileState extends State { bool _focus = false; bool _hover = false; + DateTime? _lastTap; + + void _tap(BrowseScope scope) { + final now = DateTime.now(); + final last = _lastTap; + _lastTap = now; + if (last != null && now.difference(last) < doubleClickTime) { + _lastTap = null; + scope.onPutUp(widget.episode); + } else { + scope.onSelect(widget.episode); + } + } @override Widget build(BuildContext context) { @@ -332,122 +358,92 @@ class _EpisodeTileState extends State { if (e.premiereDate != null) '${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}', ].join(' · '); - return Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (_, k) { - if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) { - scope.onOpen(DetailRoute(e)); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: selected - ? scheme.primaryContainer.withValues(alpha: 0.35) - : _hover || _focus - ? scheme.surfaceContainerHigh - : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent, - width: 1.5), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: ValueKey('episode-${e.id}'), - focusColor: Colors.transparent, - onTap: () => scope.onPick(e), - onHover: (h) { - setState(() => _hover = h); - if (h) scope.onPeek(e); - }, - onFocusChange: (f) { - setState(() => _focus = f); - if (f) scope.onPeek(e); - }, - child: Padding( - padding: const EdgeInsets.all(6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 176, - child: AspectRatio( - aspectRatio: 16 / 9, - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Stack(fit: StackFit.expand, children: [ - JellyfinImage( - client: scope.client, - image: e.ownImage(JellyfinImageType.primary) ?? - e.image(JellyfinImageType.thumb) ?? - e.image(JellyfinImageType.backdrop), - icon: Icons.tv_outlined, + return Material( + color: selected + ? scheme.primaryContainer.withValues(alpha: 0.35) + : _hover || _focus + ? scheme.surfaceContainerHigh + : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent, + width: 1.5), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('episode-${e.id}'), + focusColor: Colors.transparent, + onTap: () => _tap(scope), + onHover: (h) => setState(() => _hover = h), + onFocusChange: (f) => setState(() => _focus = f), + child: Padding( + padding: const EdgeInsets.all(6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 176, + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack(fit: StackFit.expand, children: [ + JellyfinImage( + client: scope.client, + image: e.ownImage(JellyfinImageType.primary) ?? + e.image(JellyfinImageType.thumb) ?? + e.image(JellyfinImageType.backdrop), + icon: Icons.tv_outlined, + ), + if (progress > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: progress, minHeight: 3, backgroundColor: Colors.black54), ), - if (progress > 0) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: LinearProgressIndicator( - value: progress, minHeight: 3, backgroundColor: Colors.black54), + if (played || selected) + Positioned( + top: 4, + right: 4, + child: CircleAvatar( + radius: 10, + backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20), + child: Icon(Icons.check, + size: 13, color: selected ? scheme.onPrimary : Colors.white), ), - if (played || selected) - Positioned( - top: 4, - right: 4, - child: CircleAvatar( - radius: 10, - backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20), - child: Icon(Icons.check, - size: 13, color: selected ? scheme.onPrimary : Colors.white), - ), - ), - ]), + ), + ]), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.titleSmall?.copyWith( + color: selected ? scheme.primary : played ? Colors.grey : null, + ), ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Expanded( - child: Text( - e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: t.titleSmall?.copyWith( - color: selected ? scheme.primary : played ? Colors.grey : null, - ), - ), - ), - if (_hover) - IconButton( - tooltip: 'Details (I)', - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor(width: 24, height: 22), - iconSize: 18, - onPressed: () => scope.onOpen(DetailRoute(e)), - icon: const Icon(Icons.info_outline), - ), - ]), - Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)), - if (e.overview != null) ...[ - const SizedBox(height: 4), - Text(e.overview!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)), - ], + Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)), + if (e.overview != null) ...[ + const SizedBox(height: 4), + Text(e.overview!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)), ], - ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 7be6134..03394e3 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../../update/update_controller.dart'; import '../../update/updater.dart'; +import 'settings_page.dart'; import 'ui.dart'; import 'update_indicator.dart'; @@ -41,7 +42,8 @@ class AboutPanel extends StatelessWidget { @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; - return Panel( + return SettingsPanel( + title: 'About', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/apps/desktop/lib/src/screens/home/media_picker.dart b/apps/desktop/lib/src/screens/home/media_picker.dart index 7e0a259..d50f2eb 100644 --- a/apps/desktop/lib/src/screens/home/media_picker.dart +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -1,34 +1,25 @@ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../../jellyfin/accounts.dart'; -import '../../jellyfin/browse_scope.dart' show BrowseStart; -import '../../jellyfin/picker_source.dart'; +import '../../jellyfin/browse_scope.dart' show JellyfinSelection; import '../../widgets/media_input.dart'; import 'picked_media.dart'; -import 'ui.dart'; export 'picked_media.dart'; -const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts']; - +/// The Library's sources. enum MediaKind { jellyfin, link, file } -/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the -/// Library tab) so it survives rebuilds and tab switches. +/// State of the Library tab, owned by the shell so it survives rebuilds +/// and tab switches: the source shown, the link field and its relay mode, +/// the selected Jellyfin card (details panel) and the last Jellyfin pick. class MediaPickerController extends ChangeNotifier { MediaPickerController( {String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source}) : link = TextEditingController(text: linkUrl), file = TextEditingController(text: mediaPath), _jellyfin = jellyfin, - _source = source ?? - (linkUrl.isNotEmpty - ? MediaKind.link - : mediaPath.isEmpty && jellyfin != null - ? MediaKind.jellyfin - : MediaKind.file) { + _selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId), + _source = source ?? MediaKind.jellyfin { link.addListener(notifyListeners); file.addListener(notifyListeners); } @@ -36,8 +27,9 @@ class MediaPickerController extends ChangeNotifier { final TextEditingController link; final TextEditingController file; MediaKind _source; - String? _notice; LinkRelay _linkRelay = LinkRelay.direct; + JellyfinMedia? _jellyfin; + JellyfinSelection? _selected; LinkRelay get linkRelay => _linkRelay; set linkRelay(LinkRelay v) { @@ -45,17 +37,23 @@ class MediaPickerController extends ChangeNotifier { _linkRelay = v; notifyListeners(); } - JellyfinMedia? _jellyfin; - /// The Jellyfin item picked in the browser (kept while another source - /// is shown). + /// The last Jellyfin item picked (restored from the last put-up). JellyfinMedia? get jellyfin => _jellyfin; - /// Picks a Jellyfin item and switches to the Jellyfin source. + /// Picks a Jellyfin item: selects its card and switches to Jellyfin. void pickJellyfin(JellyfinMedia? media) { _jellyfin = media; + _selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId); _source = MediaKind.jellyfin; - _notice = null; + notifyListeners(); + } + + /// The selected Jellyfin card (details panel open), or null. + JellyfinSelection? get selected => _selected; + void select(JellyfinSelection? s) { + if (s == _selected && s?.item == _selected?.item) return; + _selected = s; notifyListeners(); } @@ -63,18 +61,10 @@ class MediaPickerController extends ChangeNotifier { set source(MediaKind v) { if (v == _source) return; _source = v; - _notice = null; notifyListeners(); } - /// One-line hint under the fields (e.g. the file dialog failed). - String? get notice => _notice; - set notice(String? v) { - _notice = v; - notifyListeners(); - } - - /// What the visible source has selected; null = nothing. + /// What the Link or File source holds; for Jellyfin the last pick. PickedMedia? get selection { switch (_source) { case MediaKind.link: @@ -107,292 +97,3 @@ class MediaPickerController extends ChangeNotifier { super.dispose(); } } - -/// Source segments (Jellyfin / Link / File) and the matching input. -/// Ctrl+V anywhere in the picker outside a text field pastes a link or path. -class MediaPicker extends StatefulWidget { - final MediaPickerController controller; - final ValueChanged? onChanged; - /// Enter in a field. - final VoidCallback? onSubmit; - final VoidCallback? onOpenIntegrations; - final bool enabled; - final bool autofocus; - /// Defaults to [JellyfinAccounts.instance]. - final JellyfinAccounts? jellyfin; - /// Jellyfin source only: the browser's "bigger" toggle. - final VoidCallback? onToggleExpanded; - final bool expanded; - /// Where the Jellyfin browser opens instead of its remembered location. - final BrowseStart? jellyfinStart; - /// False hides the File source. - final bool allowFile; - /// Shown under the Link field instead of the default help. - final String? linkHelp; - /// Shown under the File field instead of the default help. - final String? fileHelp; - /// Offer the [LinkRelay] choice under the Link field (in a session). - final bool linkRelayChoice; - const MediaPicker({ - super.key, - this.jellyfinStart, - this.allowFile = true, - this.linkHelp, - this.fileHelp, - this.linkRelayChoice = false, - required this.controller, - this.onChanged, - this.onSubmit, - this.onOpenIntegrations, - this.enabled = true, - this.autofocus = false, - this.jellyfin, - this.onToggleExpanded, - this.expanded = false, - }); - - @override - State createState() => _MediaPickerState(); -} - -class _MediaPickerState extends State { - MediaPickerController get _c => widget.controller; - - @override - void initState() { - super.initState(); - _c.addListener(_changed); - } - - @override - void didUpdateWidget(MediaPicker old) { - super.didUpdateWidget(old); - if (old.controller != widget.controller) { - old.controller.removeListener(_changed); - widget.controller.addListener(_changed); - } - } - - @override - void dispose() { - _c.removeListener(_changed); - super.dispose(); - } - - void _changed() { - widget.onChanged?.call(_c.selection); - if (mounted) setState(() {}); - } - - Future _clipboard() async { - try { - return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); - } catch (_) { - return null; - } - } - - Future _pasteAnywhere() async { - final text = await _clipboard(); - if (text != null && text.isNotEmpty) _c.pasteText(text); - } - - Future _pasteLink() async { - final text = await _clipboard(); - if (text != null && text.isNotEmpty) _c.link.text = text; - } - - Future _browse() async { - try { - final files = await FilePicker.pickFiles( - dialogTitle: 'Pick media file', - type: FileType.custom, - allowedExtensions: videoExtensions, - ); - final path = files.isEmpty ? null : files.first.path; - if (path != null) { - _c.file.text = path; - _c.notice = null; - } - } catch (e) { - _c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead'; - } - } - - KeyEventResult _onKey(FocusNode node, KeyEvent e) { - final ctrl = HardwareKeyboard.instance.isControlPressed || - HardwareKeyboard.instance.isMetaPressed; - if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) { - return KeyEventResult.ignored; - } - // A focused text field handles its own paste. - final inField = FocusManager.instance.primaryFocus?.context - ?.findAncestorStateOfType() != - null; - if (inField) return KeyEventResult.ignored; - _pasteAnywhere(); - return KeyEventResult.handled; - } - - @override - Widget build(BuildContext context) { - final source = _c.source; - final Widget content = switch (source) { - MediaKind.jellyfin => JellyfinPickerSource( - accounts: widget.jellyfin ?? JellyfinAccounts.instance, - selected: _c.jellyfin, - onPick: _c.pickJellyfin, - onOpenIntegrations: widget.onOpenIntegrations, - onToggleExpanded: widget.onToggleExpanded, - expanded: widget.expanded, - start: widget.jellyfinStart, - ), - MediaKind.link => _FieldRow( - key: const ValueKey('media-link'), - field: TextField( - controller: _c.link, - autofocus: widget.autofocus, - decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'), - keyboardType: TextInputType.url, - onSubmitted: (_) => widget.onSubmit?.call(), - ), - button: OutlinedButton.icon( - onPressed: _pasteLink, - icon: const Icon(Icons.content_paste, size: 18), - label: const Text('Paste'), - ), - help: widget.linkRelayChoice - ? _linkRelayHelp(_c.linkRelay) - : widget.linkHelp ?? - 'A direct http(s) link to a video file; everyone streams it from there. ' - 'Jellyfin links are shared as the item, without your sign-in.', - notice: _c.notice, - extra: widget.linkRelayChoice - ? SegmentedButton( - key: const ValueKey('link-relay'), - showSelectedIcon: false, - segments: const [ - ButtonSegment(value: LinkRelay.direct, label: Text('Everyone fetches it')), - ButtonSegment(value: LinkRelay.server, label: Text('Server fetches it')), - ButtonSegment(value: LinkRelay.me, label: Text('Through me')), - ], - selected: {_c.linkRelay}, - onSelectionChanged: (s) => _c.linkRelay = s.first, - ) - : null, - ), - MediaKind.file => _FieldRow( - key: const ValueKey('media-file'), - field: TextField( - controller: _c.file, - decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'), - onSubmitted: (_) => widget.onSubmit?.call(), - ), - button: OutlinedButton.icon( - onPressed: _browse, - icon: const Icon(Icons.folder_open, size: 18), - label: const Text('Browse'), - ), - help: widget.fileHelp ?? - 'Friends stream it from you while you watch. Optional — you can host without media.', - notice: _c.notice, - ), - }; - final segments = Align( - alignment: Alignment.centerLeft, - child: SegmentedButton( - key: const ValueKey('media-source'), - showSelectedIcon: false, - segments: [ - const ButtonSegment( - value: MediaKind.jellyfin, - icon: Icon(Icons.video_library_outlined), - label: Text('Jellyfin')), - const ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')), - const ButtonSegment( - value: MediaKind.file, - icon: Icon(Icons.insert_drive_file_outlined), - label: Text('File')), - ].where((s) => widget.allowFile || s.value != MediaKind.file).toList(), - selected: {source}, - onSelectionChanged: (s) => _c.source = s.first, - ), - ); - return Focus( - autofocus: widget.autofocus, - onKeyEvent: _onKey, - child: AbsorbPointer( - absorbing: !widget.enabled, - // The browser fills the height it is given; in an unbounded - // (scrolling) parent it gets a fixed one. - child: LayoutBuilder(builder: (context, box) { - final fill = source == MediaKind.jellyfin; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min, - children: [ - segments, - const SizedBox(height: kGap), - if (!fill) - content - else if (box.hasBoundedHeight) - Expanded(child: content) - else - SizedBox(height: jellyfinPickerHeight, child: content), - ], - ); - }), - ), - ); - } -} - -/// Height of the Jellyfin browser when the picker sits in a scroll view. -const jellyfinPickerHeight = 640.0; - -class _FieldRow extends StatelessWidget { - final Widget field; - final Widget button; - final String help; - final String? notice; - /// Under the field, above the help (e.g. the link relay choice). - final Widget? extra; - const _FieldRow( - {super.key, required this.field, required this.button, required this.help, this.notice, this.extra}); - - @override - Widget build(BuildContext context) { - final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey); - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded(child: field), - const SizedBox(width: 12), - button, - ], - ), - if (extra != null) ...[const SizedBox(height: 10), extra!], - const SizedBox(height: 8), - Text(notice ?? help, style: small), - ], - ), - ), - ); - } -} - -String _linkRelayHelp(LinkRelay r) => switch (r) { - LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, ' - 'without your sign-in.', - LinkRelay.server => 'The server downloads it once and streams it to everyone: for links that are ' - 'slow or blocked for some. The server must be able to reach the link; it is not shown to anyone.', - LinkRelay.me => 'This computer downloads it and passes it on through the server: for links only you ' - 'can open. Keep the app open while it plays; the link is not shown to anyone.', - }; diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index c27116d..5e34f16 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -2,8 +2,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; -import '../../playback/playback_controller.dart'; -import '../../shaders/shader_presets.dart'; import '../../state/settings.dart'; import '../../update/update_controller.dart'; import 'ui.dart'; @@ -26,7 +24,8 @@ const sessionShortcuts = [ ('Esc', 'Close the panel or fullscreen, else ask to leave'), ]; -/// One titled panel of the Settings tab. +/// One titled card of the Settings tab (rounded Material, so list tiles +/// paint their ripple on it). class SettingsPanel extends StatelessWidget { final String title; final Widget child; @@ -36,76 +35,56 @@ class SettingsPanel extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: kGap), - child: Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [SectionLabel(title), child], + child: Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + child, + ], + ), ), ), ); } } -class HwdecSetting extends StatefulWidget { - const HwdecSetting({super.key}); - - @override - State createState() => _HwdecSettingState(); -} - -class _HwdecSettingState extends State { - final _s = AppSettings.instance; - late final _custom = TextEditingController(text: _s.hwdecCustom.peek()); - - @override - void dispose() { - _custom.dispose(); - super.dispose(); - } +/// A tinted hint card at the top of a Settings page. +class SettingsHintCard extends StatelessWidget { + final IconData icon; + final String text; + const SettingsHintCard({super.key, required this.icon, required this.text}); @override Widget build(BuildContext context) { - return SignalBuilder(builder: (context) { - final mode = _s.hwdecMode.value; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + final scheme = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.only(bottom: kGap), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + ), + child: Row( children: [ - const Text('Hardware decoding'), - const SizedBox(height: 4), - const Text( - 'Applies on the next join or Reload video (Ctrl+R). Turn it off if video ' - 'shows glitches or a black picture.', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 12), - SegmentedButton( - showSelectedIcon: false, - segments: const [ - ButtonSegment(value: HwdecMode.auto, label: Text('Auto')), - ButtonSegment(value: HwdecMode.off, label: Text('Off')), - ButtonSegment(value: HwdecMode.custom, label: Text('Custom')), - ], - selected: {mode}, - onSelectionChanged: (s) => _s.setHwdecMode(s.first), - ), - if (mode == HwdecMode.custom) - Padding( - padding: const EdgeInsets.only(top: 12), - child: TextField( - controller: _custom, - decoration: fieldDecoration('mpv hwdec value', - hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'), - onChanged: _s.setHwdecCustom, - ), - ), + Icon(icon, color: scheme.primary), + const SizedBox(width: 12), + Expanded(child: Text(text)), ], - ); - }); + ), + ); } } -class ShaderSettings extends StatelessWidget { - const ShaderSettings({super.key}); +/// The .glsl files of the "Custom shaders" preset, in mpv order. +class CustomShaderFiles extends StatelessWidget { + const CustomShaderFiles({super.key}); Future _addFiles() async { final List files; @@ -130,65 +109,18 @@ class ShaderSettings extends StatelessWidget { @override Widget build(BuildContext context) { final s = AppSettings.instance; + final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant); return SignalBuilder(builder: (context) { - final preset = s.shaderPreset.value; - final quality = s.shaderQuality.value; final custom = s.customShaders.value; - final mode = PlaybackController.lastRenderMode.value; - const hint = TextStyle(fontSize: 12, color: Colors.grey); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Anime4K upscales and sharpens anime on your GPU, for you only. ' - 'Mode A suits 1080p sources, B 720p, C 480p; A+A, B+B and C+A add ' - 'a second pass. HQ is for strong GPUs (GTX 1080 and up), Fast for ' - 'weaker ones. Costs GPU time every frame and only works with ' - 'hardware video rendering. In the player: the ✨ button or ' - 'Ctrl+1…6, Ctrl+0 for off; changes apply live and are saved here.', - style: hint, - ), - if (mode == RenderMode.software || mode == RenderMode.softwareGl) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - mode == RenderMode.software - ? 'The last session used software rendering: shaders had no effect there.' - : 'The last session rendered OpenGL on the CPU: shaders will be slow.', - style: const TextStyle(fontSize: 12, color: Colors.orange), - ), - ), - const SizedBox(height: 12), Row( children: [ - const Text('Shaders'), - const SizedBox(width: 16), - DropdownButton( - value: preset, - onChanged: (p) { - if (p != null) s.setShaderPreset(p); - }, - items: [ - for (final p in ShaderPreset.values) - DropdownMenuItem(value: p, child: Text(p.label)), - ], + Expanded( + child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.', + style: muted), ), - const Spacer(), - SegmentedButton( - showSelectedIcon: false, - segments: [ - for (final q in ShaderQuality.values) - ButtonSegment(value: q, label: Text(q.label)), - ], - selected: {quality}, - onSelectionChanged: (q) => s.setShaderQuality(q.first), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - const Expanded(child: Text('Custom shaders (run in this order)')), TextButton.icon( onPressed: _addFiles, icon: const Icon(Icons.add, size: 18), @@ -197,7 +129,7 @@ class ShaderSettings extends StatelessWidget { ], ), if (custom.isEmpty) - const Text('None picked.', style: hint) + Text('None picked.', style: muted) else for (final (i, path) in custom.indexed) Row( @@ -205,15 +137,13 @@ class ShaderSettings extends StatelessWidget { Expanded( child: Tooltip( message: path, - child: Text(path.split(RegExp(r'[/\\]')).last, - overflow: TextOverflow.ellipsis), + child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis), ), ), IconButton( tooltip: 'Remove', icon: const Icon(Icons.close, size: 18), - onPressed: () => - s.setCustomShaders([...custom]..removeAt(i)), + onPressed: () => s.setCustomShaders([...custom]..removeAt(i)), ), ], ), @@ -264,8 +194,10 @@ class UpdateSettings extends StatelessWidget { const SizedBox(height: 8), SignalBuilder( builder: (context) => SwitchListTile( + key: const ValueKey('auto-check-updates'), contentPadding: EdgeInsets.zero, - title: const Text('Check automatically on start'), + title: const Text('Check for updates on start'), + subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'), value: s.autoCheckUpdates.value, onChanged: s.setAutoCheckUpdates, ), @@ -275,26 +207,6 @@ class UpdateSettings extends StatelessWidget { } } -class MpvControlsSetting extends StatelessWidget { - const MpvControlsSetting({super.key}); - - @override - Widget build(BuildContext context) { - final s = AppSettings.instance; - return SignalBuilder( - builder: (context) => SwitchListTile( - key: const ValueKey('mpv-controls'), - contentPadding: EdgeInsets.zero, - title: const Text('Experimental: mpv-style controls'), - subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with ' - 'chapters and the cached range, flat buttons and menus. Applies right away.'), - value: s.mpvControls.value, - onChanged: s.setMpvControls, - ), - ); - } -} - class AutoAdvanceSetting extends StatelessWidget { const AutoAdvanceSetting({super.key}); @@ -306,8 +218,8 @@ class AutoAdvanceSetting extends StatelessWidget { key: const ValueKey('auto-advance'), contentPadding: EdgeInsets.zero, title: const Text('Play the next episode automatically'), - subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; ' - 'anyone can cancel it on their screen.'), + subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; ' + 'anyone can cancel it'), value: s.autoAdvance.value, onChanged: s.setAutoAdvance, ), @@ -315,6 +227,26 @@ class AutoAdvanceSetting extends StatelessWidget { } } +class HideReReleasesSetting extends StatelessWidget { + const HideReReleasesSetting({super.key}); + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + return SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('hide-re-releases'), + contentPadding: EdgeInsets.zero, + title: const Text('Hide re-releases in Latest'), + subtitle: const Text('Skips episodes added more than 30 days after they aired ' + '(re-uploads, upgrades); also in each Latest row\'s ⋮ menu'), + value: s.hideReReleases.value, + onChanged: s.setHideReReleases, + ), + ); + } +} + class ShortcutList extends StatelessWidget { const ShortcutList({super.key}); diff --git a/apps/desktop/lib/src/screens/session/swap_panel.dart b/apps/desktop/lib/src/screens/session/swap_panel.dart deleted file mode 100644 index c726a7c..0000000 --- a/apps/desktop/lib/src/screens/session/swap_panel.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../jellyfin/accounts.dart'; -import '../../jellyfin/browse_scope.dart' show BrowseStart; -import '../home/media_picker.dart'; - -/// The Library tab's body: the [MediaPicker] (Jellyfin browser, Link, -/// File) and a button that puts the pick up for everyone. A file streams from this computer: the host's app -/// serves it, anyone else's goes through the server's relay. [onSwap] -/// returns an error text, or null when sent. -class SwapPanel extends StatefulWidget { - final MediaPickerController controller; - final JellyfinAccounts accounts; - final bool isHost; - final BrowseStart? jellyfinStart; - final Future Function(PickedMedia media) onSwap; - - /// After a pick went up. - final VoidCallback? onDone; - final VoidCallback? onOpenIntegrations; - - /// Set when nothing can go up (offline): shown instead of the pick, and - /// the button is off. - final String? disabledHint; - const SwapPanel({ - super.key, - required this.controller, - required this.accounts, - required this.isHost, - required this.onSwap, - this.onDone, - this.jellyfinStart, - this.onOpenIntegrations, - this.disabledHint, - }); - - @override - State createState() => _SwapPanelState(); -} - -class _SwapPanelState extends State { - bool _busy = false; - String? _error; - - Future _swap() async { - final media = widget.controller.selection; - if (media == null || _busy || widget.disabledHint != null) return; - setState(() { - _busy = true; - _error = null; - }); - String? error; - try { - error = await widget.onSwap(media); - } catch (e) { - error = '$e'; - } - if (!mounted) return; - setState(() { - _busy = false; - _error = error; - }); - if (error == null) widget.onDone?.call(); - } - - @override - Widget build(BuildContext context) { - final t = Theme.of(context).textTheme; - final hint = widget.disabledHint; - return Padding( - key: const ValueKey('swap-panel'), - padding: const EdgeInsets.fromLTRB(20, 16, 12, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: MediaPicker( - controller: widget.controller, - jellyfin: widget.accounts, - enabled: !_busy, - linkRelayChoice: true, - fileHelp: widget.isHost - ? 'Friends stream it from your computer.' - : 'Streams from your computer through the server. Keep the app open while it plays.', - jellyfinStart: widget.jellyfinStart, - onOpenIntegrations: widget.onOpenIntegrations, - onSubmit: _swap, - ), - ), - ), - const SizedBox(height: 12), - ListenableBuilder( - listenable: widget.controller, - builder: (context, _) { - final sel = widget.controller.selection; - return Row( - children: [ - Expanded( - child: Text( - _error ?? - hint ?? - (sel == null - ? 'Pick something to watch' - : 'Put up: ${sel.label}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: t.bodyMedium?.copyWith( - color: _error != null - ? Theme.of(context).colorScheme.error - : null, - ), - ), - ), - const SizedBox(width: 12), - FilledButton.icon( - key: const ValueKey('swap-go'), - onPressed: sel == null || _busy || hint != null - ? null - : _swap, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.swap_horiz), - label: const Text('Put it up for everyone'), - ), - ], - ); - }, - ), - ], - ), - ); - } -} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 6d40afa..f9494c6 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -89,7 +89,10 @@ class ToastItem { final int id; final String text; final DateTime at; - const ToastItem({required this.id, required this.text, required this.at}); + /// The member a feed or chat toast is about (their name, as the feed + /// carries it; [text] starts with it); null for the app's own toasts. + final String? actor; + const ToastItem({required this.id, required this.text, required this.at, this.actor}); } const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'}; @@ -705,7 +708,7 @@ class SessionCoordinator { _toastId++; toasts.value = [ ...toasts.peek(), - ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc()) + ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) ]; } final kind = sfxForFeedAction(f.action); diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 2ab973e..26535ad 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -609,6 +609,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onLeave: _confirmLeave, videoBuilder: widget.videoBuilder, + windowControls: widget.windowControls, ), ), ), diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index de3e247..67c7ae0 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -103,8 +103,9 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, stale: m.stale, - // Only your own app knows it hosts: the state carries no host flag. - host: self && coord.hostFile != null, + // The state marks the host (`members[].host`); your own app also + // knows before the first state. + host: m.host || (self && coord.hostFile != null), self: self, ); } diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart new file mode 100644 index 0000000..3684447 --- /dev/null +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -0,0 +1,621 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../screens/home/media_picker.dart'; +import '../state/connect_prefs.dart'; +import 'motion.dart'; + +// The Library's Link source: a big paste field, a preview of what the link +// is, how the others get it (in a server), and the recent links. + +const maxRecentLinks = 12; +const _keyRecentLinks = 'recentLinks'; + +/// A link put up before, newest first in [RecentLinks]. +class RecentLink { + final String url; + final String title; + final String? thumbnail; + final DateTime at; + const RecentLink({required this.url, required this.title, this.thumbnail, required this.at}); + + Map toJson() => + {'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch}; + + static RecentLink? fromJson(Object? j) { + if (j is! Map) return null; + final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb']; + if (url is! String || url.isEmpty) return null; + return RecentLink( + url: url, + title: title is String && title.isNotEmpty ? title : LinkMedia(url).label, + thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null, + at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0), + ); + } +} + +/// [link] first, its older copy dropped, at most [maxRecentLinks]. +List pushRecentLink(List list, RecentLink link) => + [link, ...list.where((l) => l.url != link.url)].take(maxRecentLinks).toList(); + +String encodeRecentLinks(List list) => jsonEncode([for (final l in list) l.toJson()]); + +List decodeRecentLinks(Object? raw) { + try { + final j = raw is String ? jsonDecode(raw) : raw; + if (j is! List) return const []; + return [for (final e in j) ?RecentLink.fromJson(e)].take(maxRecentLinks).toList(); + } catch (_) { + return const []; + } +} + +/// The recent links, kept in the `connect_form` box next to the last link. +class RecentLinks { + static final RecentLinks instance = RecentLinks(); + + RecentLinks({Future Function()? read, Future Function(String value)? write}) + : _read = read ?? _hiveRead, + _write = write ?? _hiveWrite; + + final Future Function() _read; + final Future Function(String value) _write; + final links = ValueNotifier>(const []); + Future? _load; + + static Future _hiveRead() async => + (connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyRecentLinks); + + static Future _hiveWrite(String value) async => + (connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyRecentLinks, value); + + Future ensureLoaded() => _load ??= () async { + try { + final read = decodeRecentLinks(await _read()); + // Something added meanwhile stays on top. + var all = read; + for (final l in links.value.reversed) { + all = pushRecentLink(all, l); + } + links.value = all; + } catch (e) { + debugPrint('load recent links failed: $e'); + } + }(); + + Future add(RecentLink link) async { + links.value = pushRecentLink(links.value, link); + try { + await _write(encodeRecentLinks(links.value)); + } catch (e) { + debugPrint('save recent links failed: $e'); + } + } +} + +// ------------------------------------------------------------- preview + +/// What a pasted link is, for the preview card and the recent list. +class LinkPreview { + final String title; + /// "youtube.com · Channel", or the host. + final String subtitle; + final String? thumbnail; + const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); +} + +/// GET [uri] and return the body (oEmbed). +typedef OEmbedGetter = Future Function(Uri uri); + +Future defaultOEmbedGet(Uri uri) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); + try { + final req = await client.getUrl(uri); + final res = await req.close().timeout(const Duration(seconds: 8)); + if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri); + return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8)); + } finally { + client.close(force: true); + } +} + +bool isYouTubeLink(Uri uri) { + final h = uri.host.toLowerCase(); + return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com'); +} + +String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; + +/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title, +/// channel and thumbnail; anything else (or a failed lookup) shows the +/// file name and host. Null for text that is not an http(s) link. +Future previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async { + final uri = Uri.tryParse(url.trim()); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; + final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri)); + if (!isYouTubeLink(uri)) return plain; + try { + final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'})); + final j = jsonDecode(body); + if (j is! Map || j['title'] is! String) return plain; + final author = j['author_name']; + return LinkPreview( + title: j['title'] as String, + subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '), + thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null, + ); + } catch (_) { + return plain; + } +} + +/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ... +String agoText(DateTime at, DateTime now) { + final d = now.difference(at).inDays; + if (d < 1) return 'today'; + if (d < 2) return 'yesterday'; + if (d < 7) return '$d days ago'; + if (d < 14) return 'last week'; + if (d < 60) return '${d ~/ 7} weeks ago'; + if (d < 365) return '${d ~/ 30} months ago'; + return 'over a year ago'; +} + +// -------------------------------------------------------------- widget + +const _modes = [ + (LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'), + ( + LinkRelay.server, + Icons.dns_outlined, + 'Server fetches it', + 'Downloaded once by the server. For links slow or blocked for some.' + ), + (LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'), +]; + +String linkRelayHelp(LinkRelay r) => switch (r) { + LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, ' + 'without your sign-in.', + LinkRelay.server => 'The server downloads it once and streams it to everyone. It must be able to ' + 'reach the link; the link is not shown to anyone.', + LinkRelay.me => 'This computer downloads it and passes it on through the server. Keep the app open ' + 'while it plays; the link is not shown to anyone.', + }; + +/// The Link source. [offlineHint] set: the button is off and says why. +class LibraryLinkSource extends StatefulWidget { + final MediaPickerController controller; + final String? offlineHint; + /// Puts the link up; returns an error text, or null when sent. + final Future Function(LinkMedia media) onPutUp; + final RecentLinks recents; + final OEmbedGetter oembed; + const LibraryLinkSource({ + super.key, + required this.controller, + required this.onPutUp, + required this.recents, + this.offlineHint, + this.oembed = defaultOEmbedGet, + }); + + @override + State createState() => _LibraryLinkSourceState(); +} + +class _LibraryLinkSourceState extends State { + MediaPickerController get _c => widget.controller; + final _previews = {}; + String _previewUrl = ''; + Timer? _debounce; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + widget.recents.ensureLoaded(); + _c.link.addListener(_typed); + _typed(); + } + + @override + void dispose() { + _debounce?.cancel(); + _c.link.removeListener(_typed); + super.dispose(); + } + + void _typed() { + final url = _c.link.text.trim(); + if (url == _previewUrl) return; + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () => _preview(url)); + if (mounted) setState(() => _error = null); + } + + Future _preview(String url) async { + _previewUrl = url; + if (url.isEmpty || _previews.containsKey(url)) { + if (mounted) setState(() {}); + return; + } + final p = await previewLink(url, get: widget.oembed); + _previews[url] = p; + if (mounted) setState(() {}); + } + + Future _paste() async { + try { + final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); + if (text != null && text.isNotEmpty) _c.link.text = text; + } catch (_) {} + } + + Future _go() async { + final url = _c.link.text.trim(); + if (url.isEmpty || _busy || widget.offlineHint != null) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay)); + } catch (e) { + error = '$e'; + } + if (error == null) { + final p = _previews[url] ?? await previewLink(url, get: widget.oembed); + unawaited(widget.recents.add(RecentLink( + url: url, + title: p?.title ?? LinkMedia(url).label, + thumbnail: p?.thumbnail, + at: DateTime.now(), + ))); + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final t = Theme.of(context).textTheme; + final hint = widget.offlineHint; + final url = _c.link.text.trim(); + final preview = url.isEmpty ? null : _previews[url]; + final loading = url.isNotEmpty && !_previews.containsKey(url); + final field = TextField( + key: const ValueKey('link-field'), + controller: _c.link, + style: const TextStyle(fontSize: 16), + keyboardType: TextInputType.url, + onSubmitted: (_) => _go(), + decoration: InputDecoration( + hintText: 'https://…', + prefixIcon: const Icon(Icons.link), + suffixIcon: IconButton(onPressed: _paste, tooltip: 'Paste', icon: const Icon(Icons.content_paste)), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + ), + ); + final button = FilledButton.icon( + key: const ValueKey('link-put-up'), + onPressed: url.isEmpty || _busy || hint != null ? null : _go, + icon: _busy + ? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow), + label: Text(hint == null ? 'Put it up' : 'Play'), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 56), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); + + return SingleChildScrollView( + key: const ValueKey('media-link'), + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 780), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(18), + ), + child: Icon(Icons.link, size: 32, color: scheme.primary), + ), + ), + const SizedBox(height: 14), + Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall), + const SizedBox(height: 6), + Text( + 'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.', + textAlign: TextAlign.center, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, box) => box.maxWidth < 460 + ? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + field, + const SizedBox(height: 10), + button, + ]) + : Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), + ), + if (hint != null || _error != null) ...[ + const SizedBox(height: 8), + Text( + _error ?? hint!, + key: const ValueKey('link-hint'), + textAlign: TextAlign.center, + style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey), + ), + ], + AnimatedSize( + duration: easeTime, + curve: ease, + child: url.isEmpty + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: _PreviewCard(preview: preview, loading: loading), + ), + ), + if (hint == null) ...[ + const SizedBox(height: 28), + Text('How the others get it', style: t.titleSmall), + const SizedBox(height: 10), + LayoutBuilder(builder: (context, box) { + final cards = [ + for (final m in _modes) + _ModeCard( + key: ValueKey('link-mode-${m.$1.name}'), + icon: m.$2, + title: m.$3, + sub: m.$4, + selected: _c.linkRelay == m.$1, + onTap: () => setState(() => _c.linkRelay = m.$1), + ), + ]; + if (box.maxWidth < 560) { + return Column(children: [ + for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c], + ]); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (i, c) in cards.indexed) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded(child: c), + ], + ], + ), + ); + }), + const SizedBox(height: 8), + Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)), + ], + ValueListenableBuilder>( + valueListenable: widget.recents.links, + builder: (context, recents, _) => recents.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 32), + child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PreviewCard extends StatelessWidget { + final LinkPreview? preview; + final bool loading; + const _PreviewCard({required this.preview, required this.loading}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final p = preview; + return Container( + key: const ValueKey('link-preview'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: scheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16)), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox(width: 160, height: 90, child: _Thumb(url: p?.thumbnail)), + ), + const SizedBox(width: 16), + Expanded( + child: loading + ? const Align( + alignment: Alignment.centerLeft, + child: SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p?.title ?? 'Not a link', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + p?.subtitle ?? 'Paste an http(s) address', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Thumb extends StatelessWidget { + final String? url; + const _Thumb({required this.url}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final fallback = ColoredBox( + color: scheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.movie_outlined, color: Colors.grey)), + ); + final u = url; + if (u == null) return fallback; + return Image.network(u, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); + } +} + +class _ModeCard extends StatelessWidget { + final IconData icon; + final String title, sub; + final bool selected; + final VoidCallback onTap; + const _ModeCard({ + super.key, + required this.icon, + required this.title, + required this.sub, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: easeTime, + curve: ease, + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant), + const SizedBox(height: 8), + Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)), + const SizedBox(height: 2), + Text(sub, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + ), + ), + ), + ); + } +} + +class _RecentGrid extends StatelessWidget { + final List recents; + final ValueChanged onPick; + const _RecentGrid({required this.recents, required this.onPick}); + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + final now = DateTime.now(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Recent', style: t.titleSmall), + const SizedBox(height: 10), + LayoutBuilder(builder: (context, box) { + const gap = 12.0; + final cols = box.maxWidth > 600 ? 3 : 2; + final w = (box.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final l in recents) + SizedBox( + width: w, + child: Tooltip( + message: l.url, + waitDuration: const Duration(milliseconds: 600), + child: InkWell( + key: ValueKey('recent-link-${l.url}'), + onTap: () => onPick(l), + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _Thumb(url: l.thumbnail), + ), + ), + const SizedBox(height: 6), + Text(l.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600)), + Text( + [_host(Uri.tryParse(l.url) ?? Uri()), agoText(l.at, now)] + .where((s) => s.isNotEmpty) + .join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + ), + ), + ], + ); + }), + ], + ); + } +} diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index 6888e34..9cc072d 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -1,11 +1,22 @@ -import 'package:flutter/material.dart'; -import 'package:sync_relay_core/core.dart' show HelloMedia; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem; + +import '../files/ui/files_page.dart'; import '../jellyfin/accounts.dart'; -import '../jellyfin/browse_scope.dart' show BrowseStart; +import '../jellyfin/browse_scope.dart'; +import '../jellyfin/details_panel.dart'; +import '../jellyfin/format.dart' show pickFor; +import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary; +import '../jellyfin/picker_source.dart'; import '../jellyfin/session_media.dart'; import '../screens/home/media_picker.dart'; -import '../screens/session/swap_panel.dart'; +import '../state/session_state.dart'; +import 'library_link.dart'; +import 'motion.dart'; /// What putting up a Jellyfin pick sends: its ref (no URL, no token). /// Throws [FormatException] with a user-facing message when the server is @@ -39,18 +50,46 @@ Future restoreLastPick( if (hadMedia) c.source = source; } -/// Library (round 1): the media picker as the "put it up" view. Offline -/// it browses, with a hint that watching needs a server. -class LibraryTab extends StatelessWidget { +/// The file playing now, when it is the one put up from here last: the +/// server's current media (state JSON) is a plain file (no link, no +/// Jellyfin ref) named like [lastPath]. +String? nowPlayingFile(Map? media, String lastPath) { + if (media == null || media['url'] != null || media['jellyfin'] != null || lastPath.isEmpty) return null; + final name = lastPath.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => lastPath); + return name == media['name'] ? lastPath : null; +} + +/// Offline, nothing goes up; this says why. +const offlinePutUpHint = 'Join or host to watch together'; + +/// Window width under which the details panel floats over the library. +const detailsDockFrom = 1000.0; + +/// The Library tab: a source switch (Jellyfin / Link / Files, fading +/// through), the Jellyfin library chips beside it, and the source below. +/// Jellyfin cards select on click and open their details in a panel at +/// the right (docked from [detailsDockFrom], floating over the library +/// below it); a double-click or the panel's button puts it up. Offline +/// everything browses and the play buttons say [offlinePutUpHint]. +class LibraryTab extends StatefulWidget { final MediaPickerController controller; final JellyfinAccounts accounts; final bool online; + /// This app hosts the server: its files stream from here directly. final bool isHost; final BrowseStart? jellyfinStart; /// Puts the pick up; returns an error text, or null when sent. final Future Function(PickedMedia media) onPutUp; final VoidCallback? onPutUpDone; final VoidCallback? onOpenIntegrations; + /// Defaults to [RecentLinks.instance]. + final RecentLinks? recentLinks; + final OEmbedGetter oembed; + /// The Files source (tests inject one); defaults to [FilesPage]. + final Widget Function(BuildContext context, {required bool online, required bool relayed, + required Future Function(String path) onPlay, String? nowPlayingPath})? filesBuilder; + /// Clock for the Jellyfin Continue row (tests pin it). + final DateTime Function() now; const LibraryTab({ super.key, required this.controller, @@ -61,19 +100,323 @@ class LibraryTab extends StatelessWidget { this.onPutUpDone, this.jellyfinStart, this.onOpenIntegrations, + this.recentLinks, + this.oembed = defaultOEmbedGet, + this.filesBuilder, + this.now = DateTime.now, }); + @override + State createState() => _LibraryTabState(); +} + +class _LibraryTabState extends State { + late final BrowseController _browse = BrowseController(widget.accounts, now: widget.now); + /// What the closing panel keeps showing while it slides out. + JellyfinSelection? _shown; + + MediaPickerController get _c => widget.controller; + String? get _hint => widget.online ? null : offlinePutUpHint; + + @override + void initState() { + super.initState(); + _c.addListener(_changed); + _browse.addListener(_changed); + _shown = _c.selected; + } + + @override + void didUpdateWidget(LibraryTab old) { + super.didUpdateWidget(old); + if (old.controller != widget.controller) { + old.controller.removeListener(_changed); + widget.controller.addListener(_changed); + } + } + + @override + void dispose() { + _c.removeListener(_changed); + _browse.dispose(); + super.dispose(); + } + + void _changed() { + if (!mounted) return; + setState(() { + if (_c.selected != null) _shown = _c.selected; + }); + } + + // ------------------------------------------------------------- put up + + Future _put(PickedMedia media) async { + String? error; + try { + error = await widget.onPutUp(media); + } catch (e) { + error = '$e'; + } + if (error == null) widget.onPutUpDone?.call(); + return error; + } + + /// A Jellyfin card or episode: a series goes up as its next episode. + Future _putUpJellyfin(JellyfinSelection sel) async { + if (!widget.online) return offlinePutUpHint; + final account = widget.accounts.account(sel.serverId); + final client = widget.accounts.clientFor(sel.serverId); + if (account == null || client == null) return 'that Jellyfin server is not linked any more'; + JellyfinItem? item = sel.item; + try { + item ??= await client.item(sel.itemId); + item = await playableFor(client, item); + } on JellyfinException catch (e) { + return e.message; + } + if (item == null) return 'nothing to play in there'; + return _put(pickFor(account, item)); + } + + /// Double-click on a card: put it up, errors as a snack bar. + Future _doublePutUp(JellyfinSelection sel) async { + _c.select(sel); + if (!widget.online) return; + final error = await _putUpJellyfin(sel); + if (error != null && mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); + } + } + + // -------------------------------------------------------------- paste + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; + if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) { + return KeyEventResult.ignored; + } + // A focused text field handles its own paste. + final inField = + FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null; + if (inField) return KeyEventResult.ignored; + unawaited(_pasteLink()); + return KeyEventResult.handled; + } + + /// Ctrl+V outside a field: an http(s) link goes to the Link source. + Future _pasteLink() async { + String? text; + try { + text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim(); + } catch (_) {} + if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return; + _c.link.text = text; + _c.source = MediaKind.link; + } + + // -------------------------------------------------------------- build + @override Widget build(BuildContext context) { - return SwapPanel( - controller: controller, - accounts: accounts, - isHost: isHost, - jellyfinStart: jellyfinStart, - onSwap: onPutUp, - onDone: onPutUpDone, - onOpenIntegrations: onOpenIntegrations, - disabledHint: online ? null : 'Join or host to watch together', + final source = _c.source; + final header = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: LayoutBuilder(builder: (context, box) { + // Narrow: icons only, so the chips keep some room. + final labels = box.maxWidth >= 620; + ButtonSegment seg(MediaKind k, IconData icon, String label) => ButtonSegment( + value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label); + return Row( + children: [ + SegmentedButton( + key: const ValueKey('media-source'), + showSelectedIcon: false, + segments: [ + seg(MediaKind.jellyfin, Icons.video_library_outlined, 'Jellyfin'), + seg(MediaKind.link, Icons.link, 'Link'), + seg(MediaKind.file, Icons.folder_outlined, 'Files'), + ], + selected: {source}, + onSelectionChanged: (s) => _c.source = s.first, + ), + const SizedBox(width: 16), + Expanded( + child: AnimatedSwitcher( + duration: easeTime, + child: source == MediaKind.jellyfin + ? _LibraryChips(key: const ValueKey('chips'), controller: _browse) + : const SizedBox(height: 40), + ), + ), + ], + ); + }), + ); + + final Widget content = switch (source) { + MediaKind.jellyfin => Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: JellyfinPickerSource( + accounts: widget.accounts, + controller: _browse, + selected: _c.selected, + onSelect: _c.select, + onPutUp: _doublePutUp, + onOpenIntegrations: widget.onOpenIntegrations, + start: widget.jellyfinStart, + libraryStrip: false, + ), + ), + MediaKind.link => LibraryLinkSource( + controller: _c, + offlineHint: _hint, + recents: widget.recentLinks ?? RecentLinks.instance, + oembed: widget.oembed, + onPutUp: _put, + ), + MediaKind.file => _files(context), + }; + + final main = Column( + children: [ + header, + Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))), + ], + ); + + final open = source == MediaKind.jellyfin && _c.selected != null; + final shown = _shown; + final details = shown == null + ? const SizedBox.shrink() + : fadeThrough(KeyedSubtree( + key: ValueKey('${shown.serverId}/${shown.itemId}'), + child: JellyfinDetailsPanel( + controller: _browse, + selection: shown, + offlineHint: _hint, + onPutUp: _putUpJellyfin, + onClose: () => _c.select(null), + ), + )); + Widget slide(Widget child) => AnimatedSlide( + duration: easeTime, + curve: ease, + offset: open ? Offset.zero : const Offset(1, 0), + child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child), + ); + + return Focus( + key: const ValueKey('library-tab'), + autofocus: true, + onKeyEvent: _onKey, + child: LayoutBuilder(builder: (context, box) { + if (box.maxWidth >= detailsDockFrom) { + return Row( + children: [ + Expanded(child: main), + ClipRect( + child: AnimatedContainer( + duration: easeTime, + curve: ease, + width: open ? detailsPanelWidth : 0, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: detailsPanelWidth, + maxWidth: detailsPanelWidth, + child: slide(details), + ), + ), + ), + ], + ); + } + return Stack( + children: [ + Positioned.fill(child: main), + Positioned( + top: 0, + bottom: 0, + right: 0, + width: detailsPanelWidth, + child: IgnorePointer( + ignoring: !open, + child: slide(Material(elevation: 16, child: details)), + ), + ), + ], + ); + }), + ); + } + + Widget _files(BuildContext context) { + final relayed = !widget.isHost; + Future play(String path) => _put(FileMedia(path)); + return SignalBuilder(builder: (context) { + final session = SessionState.instance; + final now = widget.online + ? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath) + : null; + final b = widget.filesBuilder; + if (b != null) { + return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now); + } + return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now); + }); + } +} + +/// The current server's video libraries as chips in the Library header; a +/// click opens one (the chip of the library open is selected). +class _LibraryChips extends StatelessWidget { + final BrowseController controller; + const _LibraryChips({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([controller, controller.accounts]), + builder: (context, _) { + final accounts = controller.accounts; + final account = accounts.loaded ? controller.account : null; + final client = account == null ? null : accounts.clientFor(account.serverId); + if (account == null || client == null) return const SizedBox(height: 40); + final stack = controller.stack; + final openId = stack.length > 1 && stack[1] is LibraryRoute ? (stack[1] as LibraryRoute).folder.id : null; + return FutureBuilder>( + future: accounts.cached(account.serverId, 'views', client.userViews), + builder: (context, snap) { + final views = (snap.data ?? const []).where(isVideoLibrary).toList(); + return SizedBox( + height: 40, + child: HScroll( + builder: (c) => ListView.separated( + controller: c, + scrollDirection: Axis.horizontal, + itemCount: views.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final v = views[i]; + return Center( + child: ChoiceChip( + key: ValueKey('library-${v.id}'), + avatar: Icon(libraryIcon(v), size: 16), + showCheckmark: false, + label: Text(v.name), + selected: v.id == openId, + onSelected: (_) => v.id == openId + ? controller.popTo(0) + : controller.openTop(LibraryRoute(v)), + ), + ); + }, + ), + ), + ); + }, + ); + }, ); } } diff --git a/apps/desktop/lib/src/shell/motion.dart b/apps/desktop/lib/src/shell/motion.dart index bd66374..5d6d8df 100644 --- a/apps/desktop/lib/src/shell/motion.dart +++ b/apps/desktop/lib/src/shell/motion.dart @@ -35,12 +35,14 @@ Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher( ); } // Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65. + // Same widget shape as the incoming branch, or the old view would + // remount (and lose its state) while it fades. return FadeTransition( opacity: CurvedAnimation( parent: anim, curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn), ), - child: c, + child: ScaleTransition(scale: const AlwaysStoppedAnimation(1.0), child: c), ); }, child: child, diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index c705167..d2a36c2 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -339,15 +341,49 @@ class StripTab extends StatelessWidget { } /// Who is in the server, from the avatars: status, host / relay pills, -/// and per member force ready and kick. +/// per member force ready and kick, and the address to invite friends. Future showMembersCard(BuildContext context) => showPopover( context, at: const Alignment(0.7, -0.84), child: const MembersCard(), ); +/// "ip:port" for each of [addresses] friends could dial: IPv4, neither +/// loopback nor link-local, private (LAN) ranges first, no repeats. +List inviteAddresses(Iterable addresses, String port) { + bool private(List b) => + b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32); + final usable = [ + for (final a in addresses) + if (a.type == InternetAddressType.IPv4 && + !a.isLoopback && + !a.isLinkLocal && + !(a.rawAddress[0] == 169 && a.rawAddress[1] == 254)) + a, + ]; + final ordered = [ + ...usable.where((a) => private(a.rawAddress)), + ...usable.where((a) => !private(a.rawAddress)), + ]; + return {for (final a in ordered) '${a.address}:$port'}.toList(); +} + +/// This machine's [inviteAddresses] (empty when the interfaces can't be +/// listed). +Future> lanInviteAddresses(String port) async { + try { + final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4); + return inviteAddresses([for (final i in interfaces) ...i.addresses], port); + } catch (e) { + debugPrint('list network interfaces failed: $e'); + return const []; + } +} + class MembersCard extends StatelessWidget { - const MembersCard({super.key}); + /// This machine's invite addresses for a port (tests pass fixed ones). + final Future> Function(String port) lanAddresses; + const MembersCard({super.key, this.lanAddresses = lanInviteAddresses}); @override Widget build(BuildContext context) { @@ -392,22 +428,26 @@ class MembersCard extends StatelessWidget { style: TextStyle(color: scheme.onSurfaceVariant), ), ), - if (address != null) ...[ - const Divider(), + const Divider(), + if (address != null) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Align( alignment: Alignment.centerLeft, child: TextButton.icon( + key: const ValueKey('copy-invite'), onPressed: () => Clipboard.setData(ClipboardData(text: address)), icon: const Icon(Icons.copy, size: 16), - label: const Text('Copy address'), + label: const Text('Copy invite address'), ), ), + ) + else + _HostInvite( + port: session.lastForm.hostPort, + lanAddresses: lanAddresses, ), - ] else - const SizedBox(height: 8), ], ); }, @@ -471,7 +511,7 @@ class _MemberTile extends StatelessWidget { value: 'force', child: Text(m.ready ? 'Force unready' : 'Force ready'), ), - if (!info.self) + if (!info.self && !info.host) PopupMenuItem( value: 'kick', child: Text( @@ -484,3 +524,77 @@ class _MemberTile extends StatelessWidget { ); } } + +/// Hosting: the addresses friends on this network can join, the first +/// one behind "Copy invite address", and what friends elsewhere need. +class _HostInvite extends StatefulWidget { + final String port; + final Future> Function(String port) lanAddresses; + const _HostInvite({required this.port, required this.lanAddresses}); + + @override + State<_HostInvite> createState() => _HostInviteState(); +} + +class _HostInviteState extends State<_HostInvite> { + late final Future> _addresses = widget.lanAddresses(widget.port); + + @override + Widget build(BuildContext context) { + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return FutureBuilder>( + future: _addresses, + builder: (context, snap) { + final list = snap.data ?? const []; + void copy(String a) => Clipboard.setData(ClipboardData(text: a)); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + key: const ValueKey('host-invite'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + key: const ValueKey('copy-invite'), + onPressed: list.isEmpty ? null : () => copy(list.first), + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy invite address'), + ), + if (snap.connectionState != ConnectionState.done) + const SizedBox.shrink() + else if (list.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text('No network address found', style: TextStyle(color: muted)), + ) + else + for (final a in list) + InkWell( + key: ValueKey('invite-$a'), + borderRadius: BorderRadius.circular(8), + onTap: () => copy(a), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))), + Icon(Icons.copy, size: 14, color: muted), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 0), + child: Text( + 'Works for friends on your network. Friends elsewhere need port ' + '${widget.port} forwarded on your router, or a server they can ' + 'all reach (like a VPS).', + style: TextStyle(color: muted, fontSize: 12), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 9269c6e..116e439 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -5,6 +5,8 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; +import '../controls/controls_probe.dart'; +import '../controls/player_settings.dart'; import '../controls/uosc/buttons.dart'; import '../controls/uosc/controls.dart'; import '../controls/uosc/timeline.dart'; @@ -19,6 +21,7 @@ import '../state/session_state.dart'; import '../state/settings.dart'; import '../widgets/shown_frame_probe.dart'; import '../widgets/text_focus.dart'; +import '../widgets/window_buttons.dart'; import 'avatar.dart'; import 'motion.dart'; @@ -52,6 +55,9 @@ class SessionPlayer extends StatefulWidget { /// Asks before leaving the server. final VoidCallback onLeave; final VideoViewBuilder? videoBuilder; + /// Window buttons in the fullscreen top bar (off in tests: they need + /// window_manager). + final bool windowControls; const SessionPlayer({ super.key, required this.mode, @@ -67,6 +73,7 @@ class SessionPlayer extends StatefulWidget { this.chatOpen = false, this.unread = 0, this.videoBuilder, + this.windowControls = true, }); @override @@ -87,6 +94,9 @@ class _SessionPlayerState extends State { int _toastSeq = 0; int _shaderNoticeSeen = 0; bool _miniHover = false; + // Whether the standard controls are up (tracked like media_kit does: + // shown on mount and on mouse movement, hidden 3 s later or on exit). + final _standardShown = ValueNotifier(true); SessionCoordinator get _coord => SessionState.instance.coordinator; bool get _full => widget.mode == PlayerMode.full; @@ -114,6 +124,7 @@ class _SessionPlayerState extends State { if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; _stopShaderWatch(); _noFocus.dispose(); + _standardShown.dispose(); super.dispose(); } @@ -131,13 +142,7 @@ class _SessionPlayerState extends State { /// Menu, Ctrl+0..6: saved as the choice (also used on the next join) /// and applied live. A null quality keeps the saved one. - void _pickShader(ShaderPreset preset, ShaderQuality? quality) { - final s = AppSettings.instance; - if (quality != null) s.setShaderQuality(quality); - s.setShaderPreset(preset); - _coord.markInput(); - _coord.playback.applyShaders(announce: true); - } + void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality); void _toggleChat() { _coord.markInput(); @@ -274,6 +279,7 @@ class _SessionPlayerState extends State { if (_full) ...[ _healthOverlay(), _waitingPill(bottom: 124), + _progressLine(), _jellyfinOverlay(), if (widget.fullscreen) _pausedParty(), ] else @@ -300,6 +306,45 @@ class _SessionPlayerState extends State { } static const _barHeight = 56.0; + static const _controlsHide = Duration(seconds: 3); + + /// Fullscreen hides the strip and its window buttons: the top bar + /// carries them while the controls are up (restore leaves fullscreen). + Widget? _fullscreenWindowButtons() { + if (!widget.fullscreen || !widget.windowControls) return null; + return Padding( + key: const ValueKey('fullscreen-window-buttons'), + padding: const EdgeInsets.only(left: 8), + child: IconTheme.merge( + data: const IconThemeData(color: Colors.white), + child: WindowButtons( + focusNode: _noFocus, + fullscreen: true, + onToggleFullscreen: widget.onToggleFullscreen, + ), + ), + ); + } + + /// The standard design's progress line while its controls are hidden + /// (the uosc timeline collapses into its own). + Widget _progressLine() => Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: SignalBuilder(builder: (context) { + final s = AppSettings.instance; + if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: _standardShown, + builder: (context, shown, _) => shown + ? const SizedBox.shrink() + : const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true), + ); + }), + ), + ); String get _title { final ref = _coord.currentRef.value; @@ -320,7 +365,7 @@ class _SessionPlayerState extends State { keyboardShortcuts: {}, visibleOnMount: true, hideMouseOnControlsRemoval: true, - controlsHoverDuration: const Duration(seconds: 3), + controlsHoverDuration: _controlsHide, // Our seek bar (chapter marks, hover title) sits in the bottom bar, // which grows by its height; the top bar keeps its row at the top. displaySeekBar: false, @@ -359,6 +404,7 @@ class _SessionPlayerState extends State { icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen), onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, ), + ?_fullscreenWindowButtons(), ], ), ), @@ -455,6 +501,7 @@ class _SessionPlayerState extends State { onPick: _pickShader, onMenuOpen: menuOpen, ), + PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode), MaterialDesktopCustomButton( icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen), onPressed: widget.onToggleFullscreen, @@ -463,20 +510,24 @@ class _SessionPlayerState extends State { } Widget _sessionControls(VideoState state) { - // Settings → "Experimental: mpv-style controls" swaps them live. + // The "Player design" setting swaps them live. return SessionControlsSwitch( - material: (_) => DoubleClickListener( - onDoubleClick: widget.onToggleFullscreen, - // Clicks on the bars are button clicks, not the video. - skipTop: _barHeight, - skipBottom: _barHeight + ChapterSeekBar.height, - child: MaterialDesktopVideoControls(state), + material: (_) => ControlsHoverProbe( + shown: _standardShown, + hideAfter: _controlsHide, + child: DoubleClickListener( + onDoubleClick: widget.onToggleFullscreen, + // Clicks on the bars are button clicks, not the video. + skipTop: _barHeight, + skipBottom: _barHeight + ChapterSeekBar.height, + child: MaterialDesktopVideoControls(state), + ), ), mpv: (_) => _mpvControls(), ); } - /// The experimental uosc-style controls: same actions as the bars above, + /// The uosc controls: same actions as the bars above, /// all through the coordinator (seeks via `player.seek`, the user path). Widget _mpvControls() { final coord = _coord; @@ -504,6 +555,7 @@ class _SessionPlayerState extends State { tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, ), + ?_fullscreenWindowButtons(), ], ), timeline: (context, expanded) => SignalBuilder( @@ -593,6 +645,7 @@ class _SessionPlayerState extends State { onChanged: (x) => playback.player.setVolume(x), ), ), + UoscPlayerSettingsButton(renderMode: playback.renderMode), UoscButton( icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, tooltip: 'Fullscreen (F)', @@ -930,7 +983,7 @@ class WaitingPill extends StatelessWidget { class _MiniSeekBar extends StatelessWidget { final bool thin; final ValueChanged? onSeek; - const _MiniSeekBar({this.thin = false, this.onSeek}); + const _MiniSeekBar({super.key, this.thin = false, this.onSeek}); @override Widget build(BuildContext context) { diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index bd80167..484feef 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import '../controls/player_settings.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/accounts_panel.dart'; +import '../schedule/animeflow.dart'; import '../screens/home/about_page.dart'; import '../screens/home/settings_page.dart'; +import '../state/session_state.dart'; import '../update/update_controller.dart'; import 'avatar.dart'; import 'motion.dart'; @@ -12,8 +15,10 @@ import 'motion.dart'; /// The Settings tab's pages, in nav order. enum SettingsSection { profile(Icons.person_outline, 'Profile'), + network(Icons.hub_outlined, 'Party & network'), player(Icons.play_circle_outline, 'Player'), integrations(Icons.extension_outlined, 'Jellyfin & integrations'), + notifications(Icons.notifications_none, 'Notifications & sounds'), updates(Icons.system_update_alt, 'Updates'), shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'), about(Icons.info_outline, 'About'); @@ -23,9 +28,9 @@ enum SettingsSection { const SettingsSection(this.icon, this.label); } -/// App-wide settings under a left nav (chips on top when narrow). The -/// pages hold the existing settings panels; the shell owns [section] so -/// the profile card can open a page. +/// App-wide settings under a left nav (chips on top when narrow), each +/// page a column of cards; the shell owns [section] so the profile card +/// can open a page. class SettingsTab extends StatelessWidget { final SettingsSection section; final ValueChanged onSection; @@ -33,6 +38,8 @@ class SettingsTab extends StatelessWidget { final UpdateController? updates; /// Defaults to [JellyfinAccounts.instance]. final JellyfinAccounts? jellyfin; + /// AnimeFlow's `/me` for the integrations page (tests pass a fake). + final Future Function()? animeFlowMe; const SettingsTab({ super.key, required this.section, @@ -40,6 +47,7 @@ class SettingsTab extends StatelessWidget { this.onEditProfile, this.updates, this.jellyfin, + this.animeFlowMe, }); List _page(BuildContext context) { @@ -64,25 +72,43 @@ class SettingsTab extends StatelessWidget { ), ), ]; + case SettingsSection.network: + return const [HostDefaultsPanel(), RecentServersPanel()]; case SettingsSection.player: - return const [ + return [ + const SettingsHintCard( + icon: Icons.tune, + text: "Player settings are also in the player's ⚙, at the right end of its controls.", + ), SettingsPanel( title: 'Player', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()], - ), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), ), - SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()), + const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), ]; case SettingsSection.integrations: + final accounts = jellyfin ?? JellyfinAccounts.instance; return [ Padding( padding: const EdgeInsets.only(bottom: 16), - child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance), + child: JellyfinAccountsPanel(accounts: accounts), + ), + AnimeFlowCard( + me: animeFlowMe ?? + () async { + final client = AnimeFlowClient.fromAccounts(accounts); + try { + return await client.me(); + } finally { + client.close(); + } + }, ), const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()), + const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()), ]; + case SettingsSection.notifications: + return const [NotificationsInfo()]; case SettingsSection.updates: return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))]; case SettingsSection.shortcuts: @@ -111,6 +137,8 @@ class SettingsTab extends StatelessWidget { padding: const EdgeInsets.only(right: 8), child: ChoiceChip( key: ValueKey('settings-${s.name}'), + avatar: Icon(s.icon, size: 18), + showCheckmark: false, label: Text(s.label), selected: section == s, onSelected: (_) => onSection(s), @@ -168,3 +196,246 @@ class SettingsTab extends StatelessWidget { ); } } + +/// Party & network: what the Host card starts with. +class HostDefaultsPanel extends StatefulWidget { + const HostDefaultsPanel({super.key}); + + @override + State createState() => _HostDefaultsPanelState(); +} + +class _HostDefaultsPanelState extends State { + final _form = SessionState.instance.lastForm; + late final _port = TextEditingController(text: _form.hostPort); + late final _secret = TextEditingController(text: _form.hostSecret); + bool _show = false; + + @override + void dispose() { + _port.dispose(); + _secret.dispose(); + super.dispose(); + } + + static String? portProblem(String v) { + final n = int.tryParse(v.trim()); + return n == null || n < 1 || n > 65535 ? 'A number from 1 to 65535' : null; + } + + Future _save() async { + try { + await SessionState.instance.saveForm(); + } catch (e) { + debugPrint('save host defaults failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + return SettingsPanel( + title: 'Hosting', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingRow( + label: 'Default port', + help: 'Where your server listens when you host; friends dial it', + control: SizedBox( + width: 120, + child: TextField( + key: const ValueKey('host-port'), + controller: _port, + keyboardType: TextInputType.number, + decoration: InputDecoration(isDense: true, errorText: portProblem(_port.text)), + onChanged: (v) { + setState(() {}); + if (portProblem(v) != null) return; + _form.hostPort = v.trim(); + _save(); + }, + ), + ), + ), + SettingRow( + label: 'Password', + help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port', + control: SizedBox( + width: 220, + child: TextField( + key: const ValueKey('host-secret'), + controller: _secret, + obscureText: !_show, + decoration: InputDecoration( + isDense: true, + hintText: 'none', + suffixIcon: IconButton( + tooltip: _show ? 'Hide' : 'Show', + icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18), + onPressed: () => setState(() => _show = !_show), + ), + ), + onChanged: (v) { + _form.hostSecret = v; + _save(); + }, + ), + ), + ), + ], + ), + ); + } +} + +/// Party & network: the servers Join offers in one click. +class RecentServersPanel extends StatelessWidget { + const RecentServersPanel({super.key}); + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return SettingsPanel( + title: 'Recent servers', + child: SignalBuilder(builder: (context) { + final list = session.recents.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Join and Host offer these in one click; remove the ones you no longer use', + style: TextStyle(color: muted, fontSize: 12)), + if (list.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('None yet.', style: TextStyle(color: muted)), + ), + for (final r in list) + ListTile( + key: ValueKey('recent-${r.key}'), + contentPadding: EdgeInsets.zero, + leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined), + title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'), + subtitle: r.secret.isEmpty ? null : const Text('password saved'), + trailing: IconButton( + tooltip: 'Remove', + icon: const Icon(Icons.close, size: 18), + onPressed: () => session.forgetServer(r), + ), + ), + ], + ); + }), + ); + } +} + +/// AnimeFlow (the Schedule tab's source): connected as whom, with which +/// rights, or why not. +class AnimeFlowCard extends StatefulWidget { + final Future Function() me; + const AnimeFlowCard({super.key, required this.me}); + + @override + State createState() => _AnimeFlowCardState(); +} + +class _AnimeFlowCardState extends State { + late Future _me = widget.me(); + + static String scopeText(Set scopes) => [ + if (scopes.contains(AnimeFlowScope.view)) 'can view', + if (scopes.contains(AnimeFlowScope.request)) 'request', + if (scopes.contains(AnimeFlowScope.manage)) 'approve', + ].join(', '); + + @override + Widget build(BuildContext context) { + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return SettingsPanel( + title: 'AnimeFlow', + child: FutureBuilder( + future: _me, + builder: (context, snap) { + final String status; + final Color color; + if (snap.connectionState != ConnectionState.done) { + status = 'checking…'; + color = muted; + } else if (snap.hasError) { + final e = snap.error; + status = e is AnimeFlowException ? e.detail : '$e'; + color = Theme.of(context).colorScheme.error; + } else { + final me = snap.data!; + final scopes = scopeText(me.scopes); + status = ['connected as ${me.name.isEmpty ? me.userId : me.name}', if (scopes.isNotEmpty) scopes] + .join(' · '); + color = Colors.greenAccent.shade400; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.downloading_outlined), + title: Text(Uri.parse(animeFlowDefaultBase).host), + subtitle: Text(status, key: const ValueKey('animeflow-status'), style: TextStyle(color: color)), + trailing: TextButton( + onPressed: snap.connectionState == ConnectionState.done + ? () => setState(() => _me = widget.me()) + : null, + child: const Text('Check again'), + ), + ), + Text( + 'Powers the Schedule tab: what is downloaded, downloading or missing, and requests. ' + 'Signs in with your Jellyfin account on the same domain.', + style: TextStyle(color: muted, fontSize: 12), + ), + ], + ); + }, + ), + ); + } +} + +/// What pops up and what makes a sound. No switches: there is nothing to +/// choose yet. +class NotificationsInfo extends StatelessWidget { + const NotificationsInfo({super.key}); + + @override + Widget build(BuildContext context) { + final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12); + Widget line(String title, String help) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(title), Text(help, style: muted)], + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingsPanel( + title: 'Pop-ups', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + line('Joins, leaves, kicks, video changes and chat', + 'Pop up bottom left for 5 s, with the person\'s picture'), + 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'), + ], + ), + ), + SettingsPanel( + title: 'Sounds', + child: line('No sounds yet', 'The app is silent; only the video makes noise'), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart index c6ed907..94f341c 100644 --- a/apps/desktop/lib/src/shell/toasts.dart +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart' show MemberView; import '../session/session_coordinator.dart' show ToastItem; import '../state/session_state.dart'; @@ -21,29 +20,26 @@ class ShellToast { ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false}); } -/// Splits a coordinator toast into who and what: feed toasts start with -/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's. -ShellToast shellToastFor(ToastItem t, Iterable names, AvatarInfo Function(String name) avatar) { - final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length); - for (final n in sorted) { - if (t.text.startsWith('$n: ')) { - return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true); - } - if (t.text.startsWith('$n ')) { - final rest = t.text.substring(n.length + 1); - final icon = rest.startsWith('joined') - ? Icons.login - : rest.startsWith('left') - ? Icons.logout - : rest.startsWith('kicked') - ? Icons.gavel - : rest.startsWith('changed') - ? Icons.swap_horiz - : Icons.info_outline; - return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); - } +/// Splits a coordinator toast into who and what: feed and chat toasts +/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob: +/// hi"); the rest are the app's. +ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { + final n = t.actor; + if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline); + if (t.text.startsWith('$n: ')) { + return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true); } - return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline); + final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text; + final icon = rest.startsWith('joined') + ? Icons.login + : rest.startsWith('left') + ? Icons.logout + : rest.startsWith('kicked') + ? Icons.gavel + : rest.startsWith('changed') + ? Icons.swap_horiz + : Icons.info_outline; + return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); } /// The coordinator's toasts as cards with avatars, bottom-left: each @@ -75,13 +71,8 @@ class _PartyToastsState extends State { if (fresh.isEmpty) return; // Only new toasts re-run this, not the party they mention. untracked(() { - final r = coord.serverState.value; - final names = [ - for (final m in r?.members ?? const []) m.name, - for (final f in coord.feedLog.value) f.actor, - ]; for (final t in fresh) { - _add(shellToastFor(t, names, (n) => avatarForName(n, coord))); + _add(shellToastFor(t, (n) => avatarForName(n, coord))); } }); }); diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 42af939..f74f172 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -18,6 +18,8 @@ const _keyShaderQuality = 'shaderQuality'; const _keyCustomShaders = 'customShaders'; const _keyAutoAdvance = 'autoAdvance'; const _keyMpvControls = 'mpvControls'; +const _keyHideReReleases = 'hideReReleases'; +const _keyProgressLine = 'progressLine'; enum HwdecMode { auto, off, custom } @@ -29,9 +31,15 @@ class AppSettings { final autoCheckUpdates = signal(true); /// Jellyfin episodes: at the end, count down 10 s and put up the next. final autoAdvance = signal(true); - /// Experimental: the session uses the mpv-style (uosc) controls - /// (`controls/uosc/`) instead of media_kit's. Applies live. + /// Player design: the uosc controls (`controls/uosc/`) instead of the + /// standard ones (media_kit's with our bars). Applies live. final mpvControls = signal(false); + /// Library: leave out of "Latest in X" what was added long after it + /// first aired (re-uploads, old seasons added late). + final hideReReleases = signal(true); + /// A 2 px progress line at the bottom of the video while the controls + /// are hidden (both designs). Applies live. + final progressLine = signal(true); /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). @@ -70,6 +78,10 @@ class AppSettings { if (advance is bool) autoAdvance.value = advance; final mpv = box.get(_keyMpvControls); if (mpv is bool) mpvControls.value = mpv; + final hideRe = box.get(_keyHideReReleases); + if (hideRe is bool) hideReReleases.value = hideRe; + final line = box.get(_keyProgressLine); + if (line is bool) progressLine.value = line; final preset = box.get(_keyShaderPreset); shaderPreset.value = ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? @@ -112,6 +124,16 @@ class AppSettings { _put(_keyMpvControls, on); } + void setProgressLine(bool on) { + progressLine.value = on; + _put(_keyProgressLine, on); + } + + void setHideReReleases(bool on) { + hideReReleases.value = on; + _put(_keyHideReReleases, on); + } + void setShaderPreset(ShaderPreset preset) { shaderPreset.value = preset; _put(_keyShaderPreset, preset.name); diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index eaa1982..72282b7 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -206,4 +206,32 @@ void main() { } }, ); + + test( + 'feed and chat toasts name their actor exactly', + timeout: const Timeout(Duration(minutes: 1)), + () async { + MediaKit.ensureInitialized(); + final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast')); + await server.start(); + final coord = SessionCoordinator(); + final friend = SyncClient(); + try { + await coord.connect(ConnectParams( + host: '127.0.0.1', port: server.boundPort, secret: 'toast', name: 'Bob')); + await coord.playback.player.setVolume(0); + await friend.connect( + wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr'); + friend.sendChat('hi there'); + await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast'); + final toasts = coord.toasts.peek(); + expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr'); + expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr'); + } finally { + await friend.dispose(); + await coord.dispose(); + await server.stop(); + } + }, + ); } diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 2335e80..59f24e0 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/files/ui/files_page.dart'; import 'package:watch_together/src/schedule/ui/schedule_page.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/screens/home/health_probe.dart'; @@ -82,7 +83,7 @@ void main() { test('pasting sorts links and paths into the right source', () { final c = MediaPickerController(); - expect(c.source, MediaKind.file); + expect(c.source, MediaKind.jellyfin); c.pasteText(' https://example.com/a.mkv '); expect(c.source, MediaKind.link); expect(c.selection, const LinkMedia('https://example.com/a.mkv')); @@ -129,13 +130,13 @@ void main() { testWidgets('tabs and settings pages switch the body', (tester) async { await pumpShell(tester, const Size(1280, 900)); - expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('tab-schedule'))); // The schedule shows a spinner while it loads: pump frames, not settle. for (var i = 0; i < 30; i++) { await tester.pump(const Duration(milliseconds: 16)); } - expect(find.byKey(const ValueKey('swap-panel')), findsNothing); + expect(find.byKey(const ValueKey('library-tab')), findsNothing); expect(find.byType(SchedulePage), findsOneWidget); await tester.tap(find.byKey(const ValueKey('tab-settings'))); for (var i = 0; i < 30; i++) { @@ -148,7 +149,7 @@ void main() { expect(find.text('Hardware decoding'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('settings-updates'))); await tester.pumpAndSettle(); - expect(find.text('Check automatically on start'), findsOneWidget); + expect(find.text('Check for updates on start'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('settings-shortcuts'))); await tester.pumpAndSettle(); expect(find.text('Toggle ready'), findsOneWidget); @@ -160,7 +161,7 @@ void main() { expect(find.text('Dev build — update checks are off'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('tab-library'))); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -229,21 +230,20 @@ void main() { expect(find.byKey(const ValueKey('join-row')), findsOneWidget); }); - testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async { + testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async { await pumpShell(tester, const Size(1280, 900)); - await tester.tap(find.text('File')); + await tester.tap(find.text('Files')); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('media-file')), findsOneWidget); - expect(find.text('Browse'), findsOneWidget); + expect(find.byType(FilesPage), findsOneWidget); await tester.tap(find.text('Link')); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('media-link')), findsOneWidget); - expect(find.text('Paste'), findsOneWidget); + expect(find.text('Watch from a link'), findsOneWidget); // Offline nothing goes up. expect(find.text('Join or host to watch together'), findsOneWidget); + expect(find.text('How the others get it'), findsNothing); await tester.tap(find.text('Jellyfin')); await tester.pumpAndSettle(); - // No linked server: sign-in right in the picker. + // No linked server: sign-in right in the library. expect(find.text('Add a Jellyfin server'), findsOneWidget); await tester.tap(find.text('Open Integrations')); await tester.pumpAndSettle(); @@ -269,14 +269,18 @@ void main() { testWidgets('narrow window: strip, cards and library fit', (tester) async { await pumpShell(tester, const Size(480, 800)); - expect(tester.takeException(), isNull); + final ex = tester.takeException(); + expect(ex, isNull); await openCard(tester, 'strip-join'); expect(find.byKey(const ValueKey('join-row')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tapAt(const Offset(5, 790)); await tester.pumpAndSettle(); - await tester.tap(find.text('Jellyfin')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); + // Narrow: the sources are icons with tooltips. + for (final source in ['Link', 'Files', 'Jellyfin']) { + await tester.tap(find.byTooltip(source)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: source); + } }); } diff --git a/apps/desktop/test/jellyfin_accounts_test.dart b/apps/desktop/test/jellyfin_accounts_test.dart index 688bee6..841d794 100644 --- a/apps/desktop/test/jellyfin_accounts_test.dart +++ b/apps/desktop/test/jellyfin_accounts_test.dart @@ -30,6 +30,20 @@ class FakeJellyfin implements JellyfinHttp { bool offline = false; int meStatus = 200; + /// Finished episodes (`/Items?filters=IsPlayed&sortBy=DatePlayed`): JoJo + /// and Black Summoner watched on 2026-10-07, after Black Summoner's + /// resume point (10-06), so its next-up episode is what continues. + List recentlyPlayed = [ + { + 'Id': 'jojo-played', 'Name': 'Played', 'Type': 'Episode', 'SeriesId': '5055eea092a576ea09eee3b6527ba857', + 'UserData': {'Played': true, 'LastPlayedDate': '2026-10-07T21:00:00Z'}, + }, + { + 'Id': 'bs-played', 'Name': 'Played', 'Type': 'Episode', 'SeriesId': 'e9aec9c14a1fcab775111844112b4cf7', + 'UserData': {'Played': true, 'LastPlayedDate': '2026-10-07T20:00:00Z'}, + }, + ]; + FakeJellyfin() { fixture('GET', '/System/Info/Public', 'pub'); on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true')); @@ -58,6 +72,7 @@ class FakeJellyfin implements JellyfinHttp { fixture('GET', '/Items/Latest', 'latest_shows'); on('GET', '/Items', (uri, _) { final q = uri.queryParameters; + if (q['filters'] == 'IsPlayed' && q['sortBy'] == 'DatePlayed') return _page(recentlyPlayed); if ((q['searchTerm'] ?? '').isNotEmpty) { return q['searchTerm']!.toLowerCase().startsWith('ari') ? JellyfinHttpResponse(200, jellyfinFixture('search')) @@ -307,10 +322,12 @@ void main() { c.dispose(); }); - test('a saved Jellyfin pick starts on the Jellyfin source', () { + test('the library starts on Jellyfin unless told otherwise; a saved pick is selected', () { const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't'); expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin); - expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file); + expect(MediaPickerController(jellyfin: m).selected!.itemId, 'i'); + expect(MediaPickerController(mediaPath: '/x.mkv', linkUrl: 'https://a/b').source, MediaKind.jellyfin); + expect(MediaPickerController(source: MediaKind.link).source, MediaKind.link); }); test('JellyfinMedia round-trips and labels', () { diff --git a/apps/desktop/test/jellyfin_browser_test.dart b/apps/desktop/test/jellyfin_browser_test.dart index 08b0216..67768b2 100644 --- a/apps/desktop/test/jellyfin_browser_test.dart +++ b/apps/desktop/test/jellyfin_browser_test.dart @@ -1,12 +1,16 @@ +import 'dart:convert'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:watch_together/src/jellyfin/browser.dart'; -import 'package:watch_together/src/screens/home/media_picker.dart'; -import 'package:watch_together/src/shell/library_tab.dart'; +import 'package:watch_together/src/jellyfin/browse_scope.dart'; +import 'package:watch_together/src/jellyfin/picker_source.dart'; +import 'package:watch_together/src/state/settings.dart'; import 'jellyfin_accounts_test.dart' show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; @@ -30,49 +34,149 @@ Future settle(WidgetTester tester, [int frames = 6]) async { } } -/// MediaPicker on the Jellyfin source with one linked fake server. -Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker( +/// Pinned "now" for the Continue row (the fixtures were captured in +/// October 2026). +final fixtureNow = DateTime.utc(2026, 10, 8, 12); + +/// What the browser reported: selections and put-ups. +class Picks { + final selected = []; + final putUp = []; +} + +/// The browser on one linked fake server; clicks select, double-clicks put +/// up (both recorded), like the Library tab wires it. +Future<(Picks, JellyfinAccounts, FakeJellyfin, BrowseController)> pumpBrowser( WidgetTester tester, Size size) async { await setSize(tester, size); // Memory store + fake transport: no real IO, so no runAsync (futures // made there would resume outside the test's fake clock). final (accounts, fake) = await linkedAccounts(); - final c = MediaPickerController(source: MediaKind.jellyfin); - addTearDown(c.dispose); - await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts))); + final controller = BrowseController(accounts, now: () => fixtureNow); + addTearDown(controller.dispose); + final picks = Picks(); + await tester.pumpWidget(app(StatefulBuilder( + builder: (context, setState) => JellyfinBrowser( + accounts: accounts, + controller: controller, + selected: picks.selected.lastOrNull, + onSelect: (s) => setState(() => picks.selected.add(s)), + onPutUp: picks.putUp.add, + ), + ))); await settle(tester); - return (c, accounts, fake); + return (picks, accounts, fake, controller); } Future openArifureta(WidgetTester tester) async { await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta'); await settle(tester, 10); expect(find.byKey(const ValueKey('search-results')), findsOneWidget); + // Series cards select; the series page opens from the details panel or + // a season card, here straight through the controller. await tester.tap(find.byKey(const ValueKey('card-$arifuretaId'))); await settle(tester); } void main() { - testWidgets('home rows: continue watching with time left, next up, latest', (tester) async { - await pumpPicker(tester, const Size(1600, 2400)); - expect(find.text('Continue watching'), findsOneWidget); - expect(find.text('Next up'), findsOneWidget); + testWidgets('home: one Continue row (resume + next up per series), latest as series cards', + (tester) async { + final (_, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 2400)); + expect(find.text('Continue'), findsOneWidget); + expect(find.text('Continue watching'), findsNothing); + expect(find.text('Next up'), findsNothing); expect(find.text('Latest in Shows'), findsOneWidget); expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget); - // Continue watching: series name on top, "S1E10 · N min left · title" below. - expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets); - expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget); - expect(find.text('Black Summoner'), findsWidgets); - expect(find.text('Monogatari'), findsWidgets); + final row = find.byKey(const ValueKey('row-continue')); + // JoJo and Black Summoner were watched yesterday; Black Summoner's + // resume point is older than its last finished episode, so its next + // up shows. Bumpkin (62 days) and the never-dated series drop out. + expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget); + expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget); + expect(find.descendant(of: row, matching: find.textContaining('S1E6')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text('From Old Country Bumpkin to Master Swordsman')), + findsNothing); + final jojo = tester.getTopLeft(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8'))); + final bs = tester.getTopLeft(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052'))); + expect(jojo.dx, lessThan(bs.dx)); + expect(controller.continueKeys, {'5055eea092a576ea09eee3b6527ba857', 'e9aec9c14a1fcab775111844112b4cf7'}); + // Latest asks for single items and groups them itself; grouped series + // from the server say how many are new. + final latest = fake.requests.lastWhere((r) => r.uri.path == '/Items/Latest').uri.queryParameters; + expect(latest['groupItems'], 'false'); + expect(find.text('30 new'), findsWidgets); expect(tester.takeException(), isNull); }); - testWidgets('series page: smart next button, season switching, pick an episode', + testWidgets('cards: click selects, double-click puts up; Show all opens newest first', (tester) async { - final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000)); + final (picks, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 1200)); + final card = find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')); + await tester.tap(card); + await tester.pump(); + expect(picks.selected.single.itemId, 'b18826be994070a79f9b52bc0f66f052'); + expect(picks.putUp, isEmpty); + // Selected: outline and check. + expect(find.descendant(of: card, matching: find.byIcon(Icons.check)), findsOneWidget); + await tester.tap(card); + await tester.pump(const Duration(milliseconds: 60)); + await tester.tap(card); + await tester.pump(); + expect(picks.putUp.single.itemId, 'b18826be994070a79f9b52bc0f66f052'); + + await tester.tap(find.byKey(const ValueKey('show-all-a656b907eb3a73532e40e44b968d0225'))); + await settle(tester); + final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters; + expect(q['sortBy'], startsWith('DateCreated')); + expect(q['sortOrder'], 'Descending'); + expect(find.widgetWithText(OutlinedButton, 'Date added'), findsOneWidget); + expect((controller.stack.last as LibraryRoute).sort, 'added'); + expect(tester.takeException(), isNull); + }); + + testWidgets('Latest: "Hide re-releases" hides items added long after airing', (tester) async { + final fake = FakeJellyfin(); + fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([ + { + 'Id': 'fresh', 'Name': 'Fresh', 'Type': 'Episode', 'SeriesId': 'sf', 'SeriesName': 'Fresh Show', + 'ParentIndexNumber': 1, 'IndexNumber': 1, + 'PremiereDate': '2026-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z', + }, + { + 'Id': 'old', 'Name': 'Old', 'Type': 'Episode', 'SeriesId': 'so', 'SeriesName': 'Old Show', + 'ParentIndexNumber': 1, 'IndexNumber': 4, + 'PremiereDate': '2012-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z', + }, + ]))); + await setSize(tester, const Size(1600, 1400)); + final (accounts, _) = await linkedAccounts(http: fake); + final controller = BrowseController(accounts, now: () => fixtureNow); + addTearDown(controller.dispose); + AppSettings.instance.hideReReleases.value = true; + addTearDown(() => AppSettings.instance.hideReReleases.value = true); + await tester.pumpWidget(app(JellyfinBrowser( + accounts: accounts, controller: controller, onSelect: (_) {}, onPutUp: (_) {}))); + await settle(tester); + expect(find.text('Fresh Show'), findsWidgets); + expect(find.text('S1E1'), findsWidgets); + expect(find.text('Old Show'), findsNothing); + await tester.tap(find.byKey(const ValueKey('latest-menu-a656b907eb3a73532e40e44b968d0225'))); + await settle(tester, 4); + await tester.tap(find.byKey(const ValueKey('hide-rereleases'))); + await settle(tester, 4); + expect(AppSettings.instance.hideReReleases.value, isFalse); + expect(find.text('Old Show'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('series page: smart next button and episodes select, double-click puts up', + (tester) async { + final (picks, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000)); await openArifureta(tester); + expect(picks.selected.last.itemId, arifuretaId); + controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'))); + await settle(tester); // Opens on the season holding next up (S2E4). - expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget); final s2 = tester.widget(find.byKey(const ValueKey('season-$season2Id'))); expect(s2.selected, isTrue); expect(find.text('1. Irregular'), findsOneWidget); @@ -83,37 +187,38 @@ void main() { expect(find.text('1. Irregular'), findsNothing); expect(find.text('1. Haulias Assemble'), findsWidgets); - // Picking an episode fills the controller. await tester.tap(find.text('2. Roar of Revolution').first); await tester.pump(); - final sel = c.selection; - expect(sel, isA()); - sel as JellyfinMedia; + final sel = picks.selected.last; expect(sel.serverId, fixtureServerId); - expect(sel.title, 'Roar of Revolution'); - expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2"); - expect(sel.baseUrl, 'https://example.invalid'); + expect(sel.item!.name, 'Roar of Revolution'); + expect(picks.putUp, isEmpty); + await tester.tap(find.text('2. Roar of Revolution').first); + await tester.pump(const Duration(milliseconds: 60)); + await tester.tap(find.text('2. Roar of Revolution').first); + await tester.pump(); + expect(picks.putUp.single.item!.name, 'Roar of Revolution'); - // The smart button picks next up. + // The smart button selects next up. await tester.tap(find.byKey(const ValueKey('series-next'))); await tester.pump(); - expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4')); + expect(picks.selected.last.item!.episodeLabel, 'S2E4'); expect(tester.takeException(), isNull); }); testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async { - await pumpPicker(tester, const Size(1600, 1000)); + final (_, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000)); final search = find.byKey(const ValueKey('jf-search')); // Focus a card, then arrow right moves to the next card. - final first = find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')); + final first = find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8')); Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first)) .requestFocus(); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType()!; - expect((ink.key as ValueKey).value, 'card-43c0f58d4f0c13e4df01d737b695fae7'); + expect((ink.key as ValueKey).value, 'card-b18826be994070a79f9b52bc0f66f052'); // "/" jumps to search. await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/'); @@ -125,21 +230,22 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); expect(find.byKey(const ValueKey('search-results')), findsNothing); - expect(find.text('Continue watching'), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); - // Into the series and back with Esc. - await openArifureta(tester); + // Into a series and back with Esc. + controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'))); + await settle(tester); expect(find.byKey(const ValueKey('series-next')), findsOneWidget); expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pump(); expect(find.byKey(const ValueKey('series-next')), findsNothing); - expect(find.text('Continue watching'), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('library grid with sort and filters', (tester) async { - final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000)); + final (_, _, fake, _) = await pumpBrowser(tester, const Size(1600, 1000)); await tester.tap(find.widgetWithText(ActionChip, 'Shows')); await settle(tester); expect(find.text('218 items'), findsOneWidget); @@ -160,33 +266,29 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns', + testWidgets('layouts: narrow and ultrawide fill rows with whole cards; rows scroll by drag', (tester) async { - await pumpPicker(tester, const Size(820, 700)); - expect(find.byKey(const ValueKey('jf-inspector')), findsNothing); + await pumpBrowser(tester, const Size(820, 700)); expect(tester.takeException(), isNull); - final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3'))); + final narrowCard = tester.getSize(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8'))); - await pumpPicker(tester, const Size(3440, 1440)); - expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget); + await pumpBrowser(tester, const Size(3440, 1440)); expect(tester.takeException(), isNull); - // Rows fill the width with whole cards. final wideCards = find.byWidgetPredicate((w) => w is InkWell && w.key is ValueKey && (w.key as ValueKey).value.startsWith('card-')); expect(wideCards.evaluate().length, greaterThan(10)); expect(narrowCard.width, greaterThan(200)); - // Hover feeds the inspector. - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(gesture.removePointer); - await gesture.addPointer(location: Offset.zero); - await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first)); - await tester.pump(); - expect( - find.descendant( - of: find.byKey(const ValueKey('jf-inspector')), - matching: find.textContaining('Rice and Thieves and Heroes')), - findsOneWidget); + // A Latest row is an HScroll: a mouse drag moves it sideways. + await setSize(tester, const Size(640, 1400)); + await settle(tester); + final row = find.byKey(const ValueKey('latest-ee75511ae395034b1e7e657707b15125')); + final list = find.descendant(of: row, matching: find.byType(Scrollable)).first; + final before = tester.state(list).position.pixels; + await tester.drag(list, const Offset(-300, 0), kind: PointerDeviceKind.mouse); + await settle(tester, 4); + expect(tester.state(list).position.pixels, greaterThan(before)); + expect(tester.takeException(), isNull); }); testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops', @@ -245,14 +347,12 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('no linked server: the picker offers sign-in inline', (tester) async { + testWidgets('no linked server: the Jellyfin source offers sign-in inline', (tester) async { await setSize(tester, const Size(1200, 900)); final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin()); await accounts.ensureLoaded(validate: false); - final c = MediaPickerController(source: MediaKind.jellyfin); - addTearDown(c.dispose); - await tester.pumpWidget(app(SingleChildScrollView( - child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {})))); + await tester.pumpWidget(app(JellyfinPickerSource( + accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {}))); await tester.pump(); expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget); expect(find.text('Open Integrations'), findsOneWidget); @@ -260,61 +360,11 @@ void main() { }); testWidgets('expired account shows a reconnect banner in the browser', (tester) async { - final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900)); + final (_, accounts, _, _) = await pumpBrowser(tester, const Size(1400, 900)); accounts.markExpired(fixtureServerId); await tester.pump(); expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget); expect(find.text('Reconnect'), findsOneWidget); expect(find.byType(JellyfinBrowser), findsOneWidget); }); - - testWidgets('Library tab: the last pick comes back; offline it only browses', - (tester) async { - await setSize(tester, const Size(1800, 1000)); - final (accounts, _) = await linkedAccounts(); - const pick = JellyfinMedia( - serverId: fixtureServerId, - baseUrl: 'https://example.invalid', - itemId: 'b18826be994070a79f9b52bc0f66f052', - title: 'Rice and Thieves and Heroes', - subtitle: 'Black Summoner · S1E6'); - await accounts.rememberPick(pick); - final c = MediaPickerController(); - addTearDown(c.dispose); - await restoreLastPick(c, accounts, () => true); - final put = []; - Future pump({required bool online}) => tester.pumpWidget(MaterialApp( - theme: ThemeData.dark(), - home: Scaffold( - body: LibraryTab( - controller: c, - accounts: accounts, - online: online, - isHost: false, - onPutUp: (m) async { - put.add(m); - return null; - }, - ), - ))); - await pump(online: false); - await settle(tester); - // The saved pick comes back on the Jellyfin source. - expect(c.selection, pick); - expect(find.byType(JellyfinBrowser), findsOneWidget); - expect(find.text('Join or host to watch together'), findsOneWidget); - final go = find.byKey(const ValueKey('swap-go')); - expect(tester.widget(go).onPressed, isNull); - - await pump(online: true); - await settle(tester); - expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget); - await tester.tap(go); - await settle(tester); - expect(put, [pick]); - tester.view.physicalSize = const Size(800, 900); - await settle(tester, 2); - expect(find.byType(JellyfinBrowser), findsOneWidget); - expect(tester.takeException(), isNull); - }); } diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index 8a5ed67..e857a07 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -6,12 +6,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/browse_scope.dart'; +import 'package:watch_together/src/jellyfin/browser.dart'; import 'package:watch_together/src/jellyfin/progress.dart'; import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/session/jellyfin_widgets.dart'; -import 'package:watch_together/src/screens/session/swap_panel.dart'; import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance; import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf; @@ -437,9 +437,7 @@ void main() { SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'), seasonId: season2Id), ])); - final c = MediaPickerController(source: MediaKind.jellyfin); - addTearDown(c.dispose); - await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts))); + await tester.pumpWidget(app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}))); await settle(tester); final s2 = tester.widget(find.byKey(const ValueKey('season-$season2Id'))); expect(s2.selected, isTrue); @@ -516,80 +514,5 @@ void main() { await tester.tap(find.byKey(const ValueKey('link-jellyfin-account'))); expect(link, 1); }); - - testWidgets('swap panel: the shared picker, File for everyone, link relay choice', (tester) async { - await setSize(tester, const Size(1400, 900)); - final (accounts, _) = await linkedAccounts(); - final c = MediaPickerController(source: MediaKind.link); - addTearDown(c.dispose); - final swapped = []; - var closed = 0; - await tester.pumpWidget(app(SwapPanel( - controller: c, - accounts: accounts, - isHost: false, - onSwap: (m) async { - swapped.add(m); - return null; - }, - onDone: () => closed++, - ))); - await settle(tester); - // A guest's file goes through the server's relay. - expect(find.text('File'), findsOneWidget); - expect(find.text('Jellyfin'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv'); - await tester.pump(); - await tester.tap(find.text('Through me')); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('swap-go'))); - await settle(tester); - expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv', relay: LinkRelay.me)]); - expect(closed, 1); - - // The Jellyfin source is the library browser; a pick goes up as is. - await tester.tap(find.text('Jellyfin')); - await settle(tester); - expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget); - - await tester.pumpWidget(app(SwapPanel( - controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x'))); - await settle(tester); - expect(find.text('File'), findsOneWidget); - await tester.tap(find.text('File')); - await settle(tester); - expect(find.text('Friends stream it from your computer.'), findsOneWidget); - }); - - testWidgets('swap panel opens on the series of the episode playing, episode highlighted', - (tester) async { - await setSize(tester, const Size(1600, 1000)); - final (accounts, _) = await linkedAccounts(); - // Somewhere else remembered: the series start wins. - await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()])); - final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List; - final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map) - .cast()); - final pick = pickForRef(accounts, - JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!; - final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin); - addTearDown(c.dispose); - await tester.pumpWidget(app(SwapPanel( - controller: c, - accounts: accounts, - isHost: false, - jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep), - onSwap: (_) async => null, - ))); - await settle(tester, 10); - expect(tester.widget(find.byKey(const ValueKey('season-$season2Id'))).selected, - isTrue); - expect(find.text('Put up: ${pick.label}'), findsOneWidget); - final row = tester.widget(find - .ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material)) - .first); - expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent), - reason: 'the episode playing is highlighted'); - }); }); } diff --git a/apps/desktop/test/library_rows_test.dart b/apps/desktop/test/library_rows_test.dart new file mode 100644 index 0000000..a97f85d --- /dev/null +++ b/apps/desktop/test/library_rows_test.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/home_rows.dart'; +import 'package:watch_together/src/screens/home/media_picker.dart'; +import 'package:watch_together/src/shell/library_link.dart'; +import 'package:watch_together/src/shell/library_tab.dart'; + +final now = DateTime.utc(2026, 10, 8, 12); +DateTime daysAgo(num d) => now.subtract(Duration(minutes: (d * 24 * 60).round())); + +JellyfinItem ep(String id, String series, + {DateTime? played, int position = 0, int? season, int? number, bool done = false}) => + JellyfinItem( + id: id, + name: id, + type: 'Episode', + seriesId: series, + seriesName: 'Series $series', + parentIndexNumber: season, + indexNumber: number, + userData: JellyfinUserData(played: done, playbackPositionTicks: position, lastPlayedDate: played), + ); + +JellyfinItem movie(String id, {DateTime? played, int position = 0}) => JellyfinItem( + id: id, + name: id, + type: 'Movie', + userData: JellyfinUserData(playbackPositionTicks: position, lastPlayedDate: played), + ); + +List ids(List l) => [for (final i in l) i.id]; + +void main() { + group('Continue row', () { + test('one card per series: resume point and next up merged', () { + final out = mergeContinue( + resume: [ep('a3', 'A', played: daysAgo(1), position: 5)], + nextUp: [ep('a4', 'A'), ep('b2', 'B')], + recentlyPlayed: [ep('b1', 'B', played: daysAgo(2), done: true)], + now: now, + ); + expect(ids(out), ['a3', 'b2']); + }); + + test('the resume item only when it is the latest activity in its series', () { + // Resumed E3 three days ago, then finished E4 yesterday: next up wins. + final out = mergeContinue( + resume: [ep('a3', 'A', played: daysAgo(3), position: 5)], + nextUp: [ep('a5', 'A')], + recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)], + now: now, + ); + expect(ids(out), ['a5']); + // No next up (last episode): the resume point stays. + expect( + ids(mergeContinue( + resume: [ep('a3', 'A', played: daysAgo(3), position: 5)], + nextUp: const [], + recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)], + now: now, + )), + ['a3']); + }); + + test('ordered by when the series was last played; newest resume point of a series', () { + final out = mergeContinue( + resume: [ + ep('a1', 'A', played: daysAgo(5), position: 5), + movie('m', played: daysAgo(0.5), position: 5), + ep('a2', 'A', played: daysAgo(4), position: 5), + ], + nextUp: [ep('c1', 'C'), ep('b1', 'B')], + recentlyPlayed: [ + ep('b0', 'B', played: daysAgo(2), done: true), + ep('c0', 'C', played: daysAgo(3), done: true), + ], + now: now, + ); + expect(ids(out), ['m', 'b1', 'c1', 'a2']); + }); + + test('series untouched for over 60 days drop out, unless nothing else is left', () { + final stale = ep('old', 'O', played: daysAgo(90), position: 5); + final unknown = ep('u1', 'U'); // next up with no play date known + expect( + ids(mergeContinue( + resume: [stale, ep('a1', 'A', played: daysAgo(10), position: 5)], + nextUp: [unknown], + now: now)), + ['a1']); + expect(ids(mergeContinue(resume: [stale], nextUp: [unknown], now: now)), ['old', 'u1']); + expect(mergeContinue(resume: const [], nextUp: const [], now: now), isEmpty); + }); + + test('a dismissed series stays out until it is played again', () { + List run(DateTime playedAt) => ids(mergeContinue( + resume: [ep('a1', 'A', played: playedAt, position: 5), ep('b1', 'B', played: daysAgo(1), position: 5)], + nextUp: const [], + dismissed: {'A': daysAgo(2)}, + now: now, + )); + expect(run(daysAgo(3)), ['b1']); + expect(run(daysAgo(0.5)), ['a1', 'b1']); + }); + + test('continueKey: the series, else the item', () { + expect(continueKey(ep('e', 'S')), 'S'); + expect(continueKey(movie('m')), 'm'); + }); + }); + + group('Latest row', () { + JellyfinItem added(String id, {String? series, String type = 'Episode', DateTime? aired, DateTime? at, int? n}) => + JellyfinItem( + id: id, + name: id, + type: type, + seriesId: series, + seriesName: series == null ? null : 'Show $series', + seriesPrimaryImageTag: series == null ? null : 'tag-$series', + parentIndexNumber: series == null ? null : 1, + indexNumber: n, + premiereDate: aired, + dateCreated: at, + ); + + test('re-releases: added more than 30 days after airing', () { + expect(isReRelease(added('x', aired: daysAgo(400), at: daysAgo(1))), isTrue); + expect(isReRelease(added('x', aired: daysAgo(20), at: daysAgo(1))), isFalse); + expect(isReRelease(added('x', aired: null, at: daysAgo(1))), isFalse); + expect(isReRelease(added('x', aired: daysAgo(400), at: null)), isFalse); + // Added before it aired (pre-release): not a re-release. + expect(isReRelease(added('x', aired: daysAgo(1), at: daysAgo(400))), isFalse); + }); + + test('episodes become series cards: one new shows S1E1, more show "N new"', () { + final cards = latestCards([ + added('a2', series: 'A', n: 2, aired: daysAgo(1), at: daysAgo(0)), + added('m', type: 'Movie', aired: daysAgo(3), at: daysAgo(1)), + added('a1', series: 'A', n: 1, aired: daysAgo(8), at: daysAgo(1)), + added('b1', series: 'B', n: 1, aired: daysAgo(2), at: daysAgo(2)), + ]); + expect([for (final c in cards) c.display.name], ['Show A', 'm', 'Show B']); + expect([for (final c in cards) c.subtitle], ['2 new', anything, 'S1E1']); + // Two new: the card is the series; one new: selecting it is the episode. + expect(cards[0].item.id, 'A'); + expect(cards[0].item.kind, JellyfinItemKind.series); + expect(cards[2].item.id, 'b1'); + expect(cards[2].display.image(JellyfinImageType.primary)!.itemId, 'B'); + expect(cards[2].display.image(JellyfinImageType.primary)!.tag, 'tag-B'); + }); + + test('re-releases are left out when hidden, kept otherwise', () { + final items = [ + added('new', series: 'A', n: 5, aired: daysAgo(1), at: daysAgo(0)), + added('old1', series: 'R', n: 1, aired: daysAgo(3000), at: daysAgo(0)), + added('old2', series: 'R', n: 2, aired: daysAgo(3000), at: daysAgo(0)), + ]; + expect([for (final c in latestCards(items)) c.display.name], ['Show A']); + expect([for (final c in latestCards(items, hideReReleases: false)) c.display.name], ['Show A', 'Show R']); + }); + + test('grouped series from the server keep their "N new"', () { + const s = JellyfinItem(id: 's', name: 'Grouped', type: 'Series', isFolder: true, childCount: 4); + expect(latestCards([s]).single.subtitle, '4 new'); + }); + }); + + group('Link source', () { + RecentLink link(String url, [int day = 1]) => + RecentLink(url: url, title: url, at: DateTime.utc(2026, 1, day)); + + test('recent links: newest first, deduped, at most 12, round-trip', () { + var list = []; + for (var i = 0; i < 15; i++) { + list = pushRecentLink(list, link('https://x/$i')); + } + expect(list, hasLength(maxRecentLinks)); + expect(list.first.url, 'https://x/14'); + list = pushRecentLink(list, link('https://x/10', 9)); + expect(list.first.url, 'https://x/10'); + expect(list.where((l) => l.url == 'https://x/10'), hasLength(1)); + final back = decodeRecentLinks(encodeRecentLinks(list)); + expect([for (final l in back) l.url], [for (final l in list) l.url]); + expect(back.first.at.isAtSameMomentAs(DateTime.utc(2026, 1, 9)), isTrue); + expect(decodeRecentLinks('nope'), isEmpty); + expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv'); + }); + + test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async { + Uri? asked; + final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async { + asked = uri; + return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'}); + }); + expect(asked!.host, 'www.youtube.com'); + expect(asked!.path, '/oembed'); + expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'}); + expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg')); + + final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1', + get: (_) => throw StateError('no lookup for plain links')); + expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null)); + + final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline')); + expect((down!.title, down.subtitle), ('abc', 'youtu.be')); + expect(await previewLink('not a link'), isNull); + }); + + test('ago text', () { + final t = DateTime.utc(2026, 10, 8); + expect(agoText(t.subtract(const Duration(hours: 3)), t), 'today'); + expect(agoText(t.subtract(const Duration(days: 1)), t), 'yesterday'); + expect(agoText(t.subtract(const Duration(days: 3)), t), '3 days ago'); + expect(agoText(t.subtract(const Duration(days: 9)), t), 'last week'); + expect(agoText(t.subtract(const Duration(days: 40)), t), '5 weeks ago'); + }); + }); + + test('now playing file: only a plain file named like the last one put up', () { + expect(nowPlayingFile({'name': 'b.mkv', 'size': 1}, '/v/b.mkv'), '/v/b.mkv'); + expect(nowPlayingFile({'name': 'b.mkv', 'url': 'http://x/b.mkv'}, '/v/b.mkv'), isNull); + expect(nowPlayingFile({'name': 'b.mkv', 'jellyfin': {}}, '/v/b.mkv'), isNull); + expect(nowPlayingFile({'name': 'c.mkv'}, '/v/b.mkv'), isNull); + expect(nowPlayingFile(null, '/v/b.mkv'), isNull); + expect(nowPlayingFile({'name': 'b.mkv'}, r'C:\v\b.mkv'), r'C:\v\b.mkv'); + }); + + test('controller: a Jellyfin pick selects its card', () { + final c = MediaPickerController(); + addTearDown(c.dispose); + const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't'); + c.pickJellyfin(m); + expect(c.source, MediaKind.jellyfin); + expect(c.selected!.itemId, 'i'); + c.select(null); + expect(c.selected, isNull); + expect(c.jellyfin, m); + }); +} diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart new file mode 100644 index 0000000..203609a --- /dev/null +++ b/apps/desktop/test/library_tab_test.dart @@ -0,0 +1,392 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; +import 'package:watch_together/src/jellyfin/browse_scope.dart'; +import 'package:watch_together/src/jellyfin/details_panel.dart'; +import 'package:watch_together/src/screens/home/media_picker.dart'; +import 'package:watch_together/src/shell/library_link.dart'; +import 'package:watch_together/src/shell/library_tab.dart'; + +import 'jellyfin_accounts_test.dart' + show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id; +import 'jellyfin_browser_test.dart' show fixtureNow, settle; + +const bsSeries = 'e9aec9c14a1fcab775111844112b4cf7'; +const bsEpisode = 'b18826be994070a79f9b52bc0f66f052'; +const bsSeason = 'fb0b77cfda15445f9319ed9d753dba23'; +const s2e2 = 'f5600f84e2c02c1f30442bf679dcbb1d'; + +JellyfinHttpResponse page(List items) => + JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length})); + +/// The fake server, plus: Arifureta in "Latest" (2 new), Black Summoner's +/// series, season and episode, and watched-state calls. +FakeJellyfin libraryFake() { + final fake = FakeJellyfin(); + final arifureta = jsonDecode(jellyfinFixture('series_detail')) as Map; + // Added when it aired: not a re-release. + fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([ + {...arifureta, 'ChildCount': 2, 'DateCreated': arifureta['PremiereDate']}, + ]))); + final episode = jsonDecode(jellyfinFixture('episode_detail')) as Map; + fake.on('GET', '/Items/$bsSeries', + (_, _) => JellyfinHttpResponse(200, jsonEncode({'Id': bsSeries, 'Name': 'Black Summoner', 'Type': 'Series'}))); + fake.on('GET', '/Shows/$bsSeries/Seasons', (_, _) => page([ + {'Id': bsSeason, 'Name': 'Season 1', 'Type': 'Season', 'IndexNumber': 1, 'SeriesId': bsSeries}, + ])); + fake.on('GET', '/Shows/$bsSeries/Episodes', (_, _) => page([episode])); + // Arifureta's episodes load on their own too (a pick restored by id). + for (final e in (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List) { + fake.on('GET', '/Items/${e['Id']}', (_, _) => JellyfinHttpResponse(200, jsonEncode(e))); + } + const ud = '{"Played":true,"PlaybackPositionTicks":0,"PlayCount":1}'; + fake.on('POST', '/UserPlayedItems/$bsEpisode', (_, _) => const JellyfinHttpResponse(200, ud)); + return fake; +} + +class FilesStub extends StatelessWidget { + final bool online, relayed; + final Future Function(String path) onPlay; + const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay}); + + @override + Widget build(BuildContext context) => Column(children: [ + Text('files online=$online relayed=$relayed'), + TextButton( + key: const ValueKey('stub-play'), + onPressed: online ? () => onPlay('/v/a.mkv') : null, + child: const Text('play'), + ), + ]); +} + +class Harness { + final MediaPickerController controller; + final JellyfinAccounts accounts; + final FakeJellyfin fake; + final put = []; + final recents = RecentLinks(read: () async => null, write: (_) async {}); + Harness(this.controller, this.accounts, this.fake); +} + +Future pumpLibrary( + WidgetTester tester, + Size size, { + bool online = true, + bool isHost = false, + MediaKind source = MediaKind.jellyfin, + Harness? reuse, + BrowseStart? start, + JellyfinMedia? pick, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final Harness h; + if (reuse != null) { + h = reuse; + } else { + final (accounts, fake) = await linkedAccounts(http: libraryFake()); + final c = MediaPickerController(source: source, jellyfin: pick); + if (pick != null) c.source = source; + addTearDown(c.dispose); + h = Harness(c, accounts, fake); + } + await tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: LibraryTab( + // A new tab each time, like the shell after "Change video". + key: UniqueKey(), + controller: h.controller, + accounts: h.accounts, + online: online, + isHost: isHost, + jellyfinStart: start, + recentLinks: h.recents, + now: () => fixtureNow, + oembed: (uri) async => jsonEncode({ + 'title': 'A video', + 'author_name': 'Chan', + }), + filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => + FilesStub(online: online, relayed: relayed, onPlay: onPlay), + onPutUp: (m) async { + h.put.add(m); + return null; + }, + ), + ), + )); + await settle(tester, 10); + return h; +} + +Finder arifuretaCard() => find.byKey(const ValueKey('card-$arifuretaId')).first; + +Future doubleTap(WidgetTester tester, Finder f) async { + await tester.tap(f); + await tester.pump(const Duration(milliseconds: 60)); + await tester.tap(f); + await settle(tester, 10); +} + +void main() { + testWidgets('source switch fades through; library chips sit in the header', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000)); + expect(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); + + await tester.tap(find.text('Link')); + await tester.pump(const Duration(milliseconds: 60)); + // Fade through: the new view waits until the old one is gone. + final incoming = tester.widget( + find.ancestor(of: find.text('Watch from a link'), matching: find.byType(FadeTransition)).first); + expect(incoming.opacity.value, 0); + await settle(tester, 10); + expect(find.text('Continue'), findsNothing); + expect(find.text('Watch from a link'), findsOneWidget); + expect(h.controller.source, MediaKind.link); + + await tester.tap(find.text('Files')); + await settle(tester, 10); + expect(find.text('files online=true relayed=true'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('stub-play'))); + await settle(tester); + expect(h.put, [const FileMedia('/v/a.mkv')]); + + await tester.tap(find.text('Jellyfin')); + await settle(tester, 10); + // A chip opens its library; again goes back home. + await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225'))); + await settle(tester, 10); + expect(find.text('218 items'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225'))); + await settle(tester, 10); + expect(find.text('Continue'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('select a card: the panel slides in; episodes pick what goes up', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000)); + expect(find.byKey(const ValueKey('jf-details')), findsNothing); + await tester.tap(arifuretaCard()); + await settle(tester, 10); + final panel = find.byKey(const ValueKey('jf-details')); + expect(panel, findsOneWidget); + // Docked at the right, 400 px. + expect(tester.getSize(panel).width, detailsPanelWidth); + expect(tester.getTopLeft(panel).dx, 1600 - detailsPanelWidth); + expect(h.controller.selected!.itemId, arifuretaId); + // Next up is S2E4; the list is its season. + expect(find.text('Put it up · S2E4'), findsOneWidget); + expect(find.byKey(const ValueKey('details-episode-$s2e2')), findsOneWidget); + await tester.ensureVisible(find.byKey(const ValueKey('details-episode-$s2e2'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('details-episode-$s2e2'))); + await settle(tester); + expect(find.text('Put it up · S2E2'), findsOneWidget); + await tester.drag(find.byKey(const ValueKey('details-list')), const Offset(0, 2000)); + await settle(tester); + await tester.tap(find.byKey(const ValueKey('details-put-up'))); + await settle(tester); + final m = h.put.single as JellyfinMedia; + expect((m.serverId, m.itemId, m.title), (fixtureServerId, s2e2, 'Burning Light')); + expect(m.subtitle, endsWith('S2E2')); + + // Another card: the panel's content cross-fades. + await tester.tap(find.byKey(const ValueKey('card-$bsEpisode'))); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.text("Arifureta: From Commonplace to World's Strongest"), findsWidgets, + reason: 'the old panel is still fading out'); + await settle(tester, 10); + expect(find.text('Put it up · S2E2'), findsNothing); + expect(find.text('Put it up · S1E6'), findsOneWidget); + + // Close: the panel slides away and the selection is gone. + await tester.tap(find.byKey(const ValueKey('details-close'))); + await settle(tester, 10); + expect(h.controller.selected, isNull); + expect(tester.getSize(find.byType(ClipRect).first).width, isNot(detailsPanelWidth)); + expect(tester.takeException(), isNull); + }); + + testWidgets('double-click a card puts it up (a series: its next episode)', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000)); + await doubleTap(tester, arifuretaCard()); + final m = h.put.single as JellyfinMedia; + expect(m.itemId, '56d6b3b65c83213fcbaeaf85c9239189'); // S2E4 + expect(h.controller.selected!.itemId, arifuretaId); + await doubleTap(tester, find.byKey(const ValueKey('card-$bsEpisode'))); + expect((h.put.last as JellyfinMedia).itemId, bsEpisode); + expect(tester.takeException(), isNull); + }); + + testWidgets('mark watched and remove from Continue reload the rows', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000)); + final row = find.byKey(const ValueKey('row-continue')); + expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('card-$bsEpisode'))); + await settle(tester, 10); + expect(find.text('Put it up · S1E6'), findsOneWidget); + final resumes = h.fake.count('GET', '/UserItems/Resume'); + + await tester.tap(find.byKey(const ValueKey('details-watched'))); + await settle(tester, 10); + expect(h.fake.count('POST', '/UserPlayedItems/$bsEpisode'), 1); + expect(h.fake.count('GET', '/UserItems/Resume'), greaterThan(resumes), reason: 'rows reload'); + + expect(find.byKey(const ValueKey('details-remove-continue')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('details-remove-continue'))); + await settle(tester, 10); + expect(h.accounts.continueDismissed(fixtureServerId), contains(bsSeries)); + expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsNothing); + expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('offline: everything browses, nothing goes up', (tester) async { + final h = await pumpLibrary(tester, const Size(1600, 1000), online: false); + await tester.tap(arifuretaCard()); + await settle(tester, 10); + final go = find.byKey(const ValueKey('details-put-up')); + expect(tester.widget(go).onPressed, isNull); + expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget); + expect(find.text(offlinePutUpHint), findsOneWidget); + await doubleTap(tester, arifuretaCard()); + expect(h.put, isEmpty); + + await tester.tap(find.text('Link')); + await settle(tester, 10); + await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv'); + await settle(tester, 10); + final linkGo = find.byKey(const ValueKey('link-put-up')); + expect(tester.widget(linkGo).onPressed, isNull); + expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)), + findsOneWidget); + expect(find.text('How the others get it'), findsNothing); + expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works'); + + await tester.tap(find.text('Files')); + await settle(tester, 10); + expect(find.text('files online=false relayed=true'), findsOneWidget); + expect(h.put, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async { + final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link); + expect(find.text('How the others get it'), findsOneWidget); + const url = 'https://www.youtube.com/watch?v=abc'; + await tester.enterText(find.byKey(const ValueKey('link-field')), url); + await settle(tester, 10); + expect(find.descendant(of: find.byKey(const ValueKey('link-preview')), matching: find.text('A video')), + findsOneWidget); + expect(find.text('youtube.com · Chan'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('link-mode-me'))); + await settle(tester); + expect(h.controller.linkRelay, LinkRelay.me); + expect(find.text(linkRelayHelp(LinkRelay.me)), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('link-put-up'))); + await settle(tester, 10); + expect(h.put, [const LinkMedia(url, relay: LinkRelay.me)]); + expect(h.recents.links.value.single.title, 'A video'); + expect(find.byKey(const ValueKey('recent-link-$url')), findsOneWidget); + + // A recent link refills the field. + await tester.enterText(find.byKey(const ValueKey('link-field')), ''); + await settle(tester, 10); + await tester.tap(find.byKey(const ValueKey('recent-link-$url'))); + await settle(tester, 10); + expect(h.controller.link.text, url); + + // Ctrl+V outside a field, on another source: the link lands in Link. + await tester.tap(find.text('Jellyfin')); + await settle(tester, 10); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'}; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + FocusManager.instance.primaryFocus?.unfocus(); + Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await settle(tester, 10); + expect(h.controller.source, MediaKind.link); + expect(h.controller.link.text, 'https://cdn.example/b.mkv'); + expect(tester.takeException(), isNull); + }); + + testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async { + await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true); + expect(find.text('files online=true relayed=false'), findsOneWidget); + }); + + testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async { + final (accounts, _) = await linkedAccounts(http: libraryFake()); + const pick = JellyfinMedia( + serverId: fixtureServerId, + baseUrl: 'https://example.invalid', + itemId: bsEpisode, + title: 'Rice and Thieves and Heroes', + subtitle: 'Black Summoner · S1E6'); + await accounts.rememberPick(pick); + final c = MediaPickerController(); + addTearDown(c.dispose); + await restoreLastPick(c, accounts, () => true); + expect(c.selected!.itemId, bsEpisode); + expect(c.source, MediaKind.jellyfin); + final h = Harness(c, accounts, FakeJellyfin()); + await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false); + expect(find.byKey(const ValueKey('jf-details')), findsOneWidget); + expect(find.text('Black Summoner'), findsWidgets); + + // Change video: the series of the episode playing, its season chosen + // and the episode highlighted. + final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List; + final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map) + .cast()); + final h2 = await pumpLibrary(tester, const Size(1600, 1000), + start: BrowseStart.seriesOf(fixtureServerId, ep), + pick: JellyfinMedia(serverId: fixtureServerId, baseUrl: 'x', itemId: ep.id, title: ep.name)); + expect(tester.widget(find.byKey(const ValueKey('season-$season2Id'))).selected, isTrue); + final tile = tester.widget(find + .ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material)) + .first); + expect((tile.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent)); + expect(find.text('Put it up · S2E1'), findsOneWidget); + expect(h2.controller.selected!.itemId, ep.id); + expect(tester.takeException(), isNull); + }); + + for (final size in const [Size(2560, 1080), Size(1100, 700), Size(900, 600)]) { + testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}, panel open, every source', + (tester) async { + final h = await pumpLibrary(tester, size); + expect(tester.takeException(), isNull); + await tester.tap(arifuretaCard()); + await settle(tester, 10); + expect(tester.takeException(), isNull); + final panel = find.byKey(const ValueKey('jf-details')); + expect(panel, findsOneWidget); + // Under 1000 px the panel floats over the library at the right edge. + expect(tester.getTopRight(panel).dx, size.width); + h.controller.link.text = 'https://cdn.example/some/long/path/Movie.Name.2024.1080p.mkv'; + for (final s in [MediaKind.link, MediaKind.file, MediaKind.jellyfin]) { + h.controller.source = s; + await settle(tester, 10); + expect(tester.takeException(), isNull, reason: '$s at $size'); + } + h.controller.select(null); + await settle(tester, 10); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart new file mode 100644 index 0000000..0e9c57c --- /dev/null +++ b/apps/desktop/test/player_settings_test.dart @@ -0,0 +1,321 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/controls/controls_probe.dart'; +import 'package:watch_together/src/controls/player_settings.dart'; +import 'package:watch_together/src/controls/uosc/controls.dart'; +import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/schedule/animeflow.dart'; +import 'package:watch_together/src/shaders/shader_presets.dart'; +import 'package:watch_together/src/shell/avatar.dart'; +import 'package:watch_together/src/shell/party_strip.dart'; +import 'package:watch_together/src/shell/settings_tab.dart'; +import 'package:watch_together/src/state/session_state.dart'; +import 'package:watch_together/src/state/settings.dart'; +import 'package:watch_together/src/update/update_controller.dart'; + +// The player's ⚙ card (both designs, never a media_kit `Video`), the +// Settings pages, the host flag in the party UI and invite addresses. + +Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)); + +void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); +} + +Future pick(WidgetTester tester, String key, String item) async { + await tester.tap(find.byKey(ValueKey(key))); + await tester.pumpAndSettle(); + await tester.tap(find.text(item).last); + await tester.pumpAndSettle(); +} + +void main() { + final s = AppSettings.instance; + final coord = SessionState.instance.coordinator; + + tearDown(() { + s.mpvControls.value = false; + s.progressLine.value = true; + s.shaderPreset.value = ShaderPreset.off; + s.shaderQuality.value = ShaderQuality.hq; + s.hwdecMode.value = HwdecMode.auto; + coord.playback.tracks.value = const []; + coord.serverState.value = null; + coord.hostFile = null; + SessionState.instance.route.value = AppRoute.home; + SessionState.instance.recents.value = const []; + }); + + group('⚙ player settings', () { + testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async { + bigWindow(tester); + coord.playback.tracks.value = const [ + MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true), + MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), + ]; + await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton()))); + await tester.tap(find.byKey(const ValueKey('player-settings'))); + await tester.pumpAndSettle(); + final card = find.byKey(const ValueKey('player-settings-card')); + expect(card, findsOneWidget); + for (final label in [ + 'Player design', + 'Progress line when hidden', + 'Upscaling shader', + 'Hardware decoding', + 'Subtitles', + 'Audio', + ]) { + expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label); + } + // Subtitles off (none selected), audio on the selected track. + expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')), + findsOneWidget); + expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')), + findsOneWidget); + + await pick(tester, 'player-design', 'uosc'); + expect(s.mpvControls.value, isTrue); + await tester.tap(find.byKey(const ValueKey('progress-line'))); + await tester.pump(); + expect(s.progressLine.value, isFalse); + await pick(tester, 'shader-preset', 'Mode B'); + expect(s.shaderPreset.value, ShaderPreset.b); + await pick(tester, 'shader-quality', 'Fast'); + expect(s.shaderQuality.value, ShaderQuality.fast); + await pick(tester, 'hwdec', 'Off'); + expect(s.hwdec.value, 'no'); + // Esc closes it. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(card, findsNothing); + }); + + testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async { + bigWindow(tester); + s.mpvControls.value = true; + await tester.pumpWidget(app(UoscControls( + hideAfter: const Duration(seconds: 1), + timeline: (context, expanded) => const SizedBox(height: 30), + right: const [UoscPlayerSettingsButton()], + ))); + final controls = tester.state(find.byType(UoscControls)); + await tester.tap(find.byKey(const ValueKey('uosc-player-settings'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget); + expect(find.text('uosc'), findsOneWidget, reason: 'the current design'); + await tester.pump(const Duration(seconds: 3)); + expect(controls.visible, isTrue, reason: 'held while the card is open'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 2)); + expect(controls.visible, isFalse); + }); + + testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async { + await tester.pumpWidget(app(UoscControls( + hideAfter: const Duration(milliseconds: 100), + timeline: (context, expanded) => const SizedBox(height: 2), + ))); + double opacity() => + tester.widget(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity; + await tester.pump(const Duration(milliseconds: 200)); + expect(opacity(), 1, reason: 'hidden, line on'); + s.setProgressLine(false); + await tester.pump(); + expect(opacity(), 0, reason: 'hidden, line off'); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(400, 300)); + addTearDown(mouse.removePointer); + await mouse.moveTo(const Offset(410, 300)); + await tester.pump(); + expect(opacity(), 1, reason: 'controls up: the full timeline'); + await tester.pump(const Duration(milliseconds: 200)); + }); + }); + + testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async { + final shown = ValueNotifier(false); + addTearDown(shown.dispose); + await tester.pumpWidget(app(ControlsHoverProbe( + shown: shown, + hideAfter: const Duration(seconds: 3), + child: const SizedBox.expand(), + ))); + await tester.pump(); + expect(shown.value, isTrue, reason: 'up on mount'); + await tester.pump(const Duration(seconds: 3)); + expect(shown.value, isFalse, reason: 'down 3 s later'); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(400, 300)); + addTearDown(mouse.removePointer); + await mouse.moveTo(const Offset(420, 300)); + await tester.pump(); + expect(shown.value, isTrue, reason: 'mouse movement'); + await tester.pump(const Duration(seconds: 3)); + expect(shown.value, isFalse); + }); + + group('Settings pages', () { + Future pumpSettings(WidgetTester tester, SettingsSection section, + {Future Function()? me, Size size = const Size(1280, 1400)}) async { + bigWindow(tester, size); + await tester.pumpWidget(app(SettingsTab( + section: section, + onSection: (_) {}, + updates: UpdateController(currentVersion: '0.0.0-dev'), + animeFlowMe: me ?? () async => throw const AnimeFlowException(401, AnimeFlowErrorCode.missingToken, 'x'), + ))); + await tester.pumpAndSettle(); + } + + testWidgets('every section renders as cards, with icons in the nav', (tester) async { + for (final section in SettingsSection.values) { + await pumpSettings(tester, section); + expect(find.text(section.label), findsWidgets, reason: section.name); + expect(tester.takeException(), isNull, reason: section.name); + } + expect(SettingsSection.values.map((s) => s.label), [ + 'Profile', + 'Party & network', + 'Player', + 'Jellyfin & integrations', + 'Notifications & sounds', + 'Updates', + 'Keyboard shortcuts', + 'About', + ]); + expect(find.byIcon(Icons.hub_outlined), findsOneWidget); + }); + + testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async { + await pumpSettings(tester, SettingsSection.player); + expect(find.textContaining("also in the player's ⚙"), findsOneWidget); + for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) { + expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key); + } + expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player'); + expect(find.text('Standard'), findsOneWidget); + await pick(tester, 'player-design', 'uosc'); + expect(s.mpvControls.value, isTrue); + }); + + testWidgets('Party & network: host defaults and recent servers with remove', (tester) async { + SessionState.instance.recents.value = const [ + RecentServer(hosted: false, host: 'vps.example', port: '8080', secret: 'pw'), + RecentServer(hosted: true, host: '127.0.0.1', port: '9000'), + ]; + await pumpSettings(tester, SettingsSection.network); + expect(find.text('Default port'), findsOneWidget); + expect(find.text('vps.example:8080'), findsOneWidget); + expect(find.text('Hosted on port 9000'), findsOneWidget); + await tester.enterText(find.byKey(const ValueKey('host-port')), '70000'); + await tester.pump(); + expect(find.text('A number from 1 to 65535'), findsOneWidget); + await tester.enterText(find.byKey(const ValueKey('host-port')), '9100'); + await tester.pump(); + expect(SessionState.instance.lastForm.hostPort, '9100'); + await tester.tap(find.descendant( + of: find.byKey(const ValueKey('recent-vps.example:8080')), matching: find.byTooltip('Remove'))); + await tester.pumpAndSettle(); + expect(find.text('vps.example:8080'), findsNothing); + SessionState.instance.lastForm.hostPort = '8080'; + }); + + testWidgets('Integrations: AnimeFlow says who you are connected as, or why not', (tester) async { + await pumpSettings(tester, SettingsSection.integrations, + me: () async => const AnimeFlowMe( + userId: 'u1', name: 'foxyz', scopes: {AnimeFlowScope.view, AnimeFlowScope.request})); + expect(find.text('connected as foxyz · can view, request'), findsOneWidget); + // A fresh tree: the card keeps its first answer otherwise. + await tester.pumpWidget(const SizedBox()); + await pumpSettings(tester, SettingsSection.integrations, + me: () async => throw const AnimeFlowException( + 401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow')); + expect(find.text('No linked Jellyfin account for AnimeFlow'), findsOneWidget); + }); + + testWidgets('narrow: icon chips instead of the side nav', (tester) async { + await pumpSettings(tester, SettingsSection.network, size: const Size(700, 1000)); + expect(find.byType(ChoiceChip), findsWidgets); + expect(find.byType(ListTile).evaluate().where((e) => e.widget.key == const ValueKey('settings-profile')), + isEmpty, reason: 'no side nav'); + final chip = tester.widget(find.byKey(const ValueKey('settings-network'))); + expect(chip.selected, isTrue); + expect((chip.avatar! as Icon).icon, Icons.hub_outlined); + expect(find.text('Default port'), findsOneWidget); + }); + }); + + group('party', () { + test('invite addresses: LAN IPv4 with the port, private first, no loopback', () { + final list = inviteAddresses([ + InternetAddress('127.0.0.1'), + InternetAddress('100.64.1.2'), + InternetAddress('192.168.1.20'), + InternetAddress('169.254.3.4'), + InternetAddress('::1'), + InternetAddress('10.0.0.5'), + InternetAddress('192.168.1.20'), + ], '8080'); + expect(list, ['192.168.1.20:8080', '10.0.0.5:8080', '100.64.1.2:8080']); + expect(inviteAddresses(const [], '8080'), isEmpty); + }); + + void online(List members) { + coord.serverState.value = StateBroadcast( + eventId: 1, + transport: 'held', + position: 0, + serverTime: DateTime.now().toUtc(), + mediaId: 0, + members: members, + ); + SessionState.instance.route.value = AppRoute.session; + } + + testWidgets('the state\'s host flag crowns that member for everyone', (tester) async { + online(const [ + MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true), + MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false), + ]); + final avatars = {for (final a in partyAvatars(coord)) a.name: a}; + expect(avatars['Hana']!.host, isTrue); + expect(avatars['Bob']!.host, isFalse); + bigWindow(tester); + await tester.pumpWidget(app(const Center(child: MembersCard()))); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.text('host')), + findsOneWidget); + expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')), + findsNothing); + // The host cannot be kicked: no Kick in its menu. + await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More'))); + await tester.pumpAndSettle(); + expect(find.text('Kick'), findsNothing); + }); + + testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async { + online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]); + coord.hostFile = (_) async => null; + SessionState.instance.lastForm.hostPort = '8080'; + bigWindow(tester); + await tester.pumpWidget(app(Center( + child: MembersCard(lanAddresses: (port) async => ['192.168.1.20:$port', '10.0.0.5:$port']), + ))); + await tester.pumpAndSettle(); + expect(find.text('Copy invite address'), findsOneWidget); + expect(find.text('192.168.1.20:8080'), findsOneWidget); + expect(find.text('10.0.0.5:8080'), findsOneWidget); + expect(find.textContaining('port 8080 forwarded'), findsOneWidget); + expect(find.textContaining('VPS'), findsOneWidget); + }); + }); +} diff --git a/apps/desktop/test/shell_test.dart b/apps/desktop/test/shell_test.dart index 1423aa3..cc85149 100644 --- a/apps/desktop/test/shell_test.dart +++ b/apps/desktop/test/shell_test.dart @@ -301,17 +301,21 @@ void main() { test('toasts: feed toasts carry the actor, the rest are the app\'s', () { AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal); final at = DateTime.now(); - final names = ['Bob', 'Bob Jr']; - final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who); + // The actor is exact: "Bob" is not mistaken for "Bob Jr". + final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr'), who); expect(chat.who!.name, 'Bob Jr'); expect(chat.text, 'hi there'); expect(chat.chat, isTrue); - final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at), names, who); + final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at, actor: 'Bob'), who); expect(joined.who!.name, 'Bob'); expect(joined.text, 'joined'); expect(joined.icon, Icons.login); - final app = shellToastFor(ToastItem(id: 3, text: 'Video froze — reloaded', at: at), names, who); + // A name that is a prefix of a sentence is not guessed without an actor. + final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who); expect(app.who, isNull); + expect(app.text, 'Bob joined'); + final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who); + expect(froze.who, isNull); }); testWidgets('toasts slide in with an avatar and leave after their time', (tester) async { diff --git a/apps/desktop/test/uosc_controls_test.dart b/apps/desktop/test/uosc_controls_test.dart index 7a40562..0c8f230 100644 --- a/apps/desktop/test/uosc_controls_test.dart +++ b/apps/desktop/test/uosc_controls_test.dart @@ -447,7 +447,7 @@ void main() { expect(find.text('material'), findsOneWidget); }); - testWidgets('Settings: "Experimental: mpv-style controls", off by default', (tester) async { + testWidgets('Settings: "Player design", Standard by default', (tester) async { final s = AppSettings.instance; s.mpvControls.value = false; addTearDown(() => s.mpvControls.value = false); @@ -464,12 +464,14 @@ void main() { ), ), )); - final toggle = find.byKey(const ValueKey('mpv-controls')); - expect(find.text('Experimental: mpv-style controls'), findsOneWidget); - expect(tester.widget(toggle).value, isFalse); - await tester.tap(toggle); - await tester.pump(); + final design = find.byKey(const ValueKey('player-design')); + expect(find.text('Player design'), findsOneWidget); + expect(tester.widget>(design).value, isFalse); + await tester.tap(design); + await tester.pumpAndSettle(); + await tester.tap(find.text('uosc').last); + await tester.pumpAndSettle(); expect(s.mpvControls.value, isTrue); - expect(tester.widget(toggle).value, isTrue); + expect(tester.widget>(design).value, isTrue); }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90665c4..6111c50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,7 +306,8 @@ behind a `JellyfinHttp` interface (tests inject a fake). Plans: - `JellyfinClient`: one server + one user. Auth (Quick Connect, password, `/Users/Me` check, logout), browsing (views, Resume, NextUp, Latest, paged - `/Items`, search, seasons, episodes), image URLs (tag + maxWidth, no token + `/Items` with `DateCreated`, search, seasons, episodes, recently played), + image URLs (tag + maxWidth, no token needed), PlaybackInfo, stream URLs, MediaSegments (a 404 = none), Playing / Progress / Stopped reports, played / unplayed / resume position. - `normalizeServerUrl`, `parseJellyfinLink`: pasted addresses and item links @@ -500,10 +501,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, matched with `matchTrack` against the source's streams (mapped to mpv ids with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes on the wire. -- **Change video**: the player's "Change video" opens the Library tab - (the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link - goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only) - through `ServerIsolate.setFile` and a matching `swap`. +- **Change video**: the player's "Change video" opens the Library tab; + Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file + through `ServerIsolate.setFile` (host) or the relay (`swapFile`). ## Desktop UI @@ -523,15 +523,19 @@ sideways until an end, then the page). ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave (asks first). The stack opens the members card (status, host / relay - pills, "via host", force ready, kick). Empty strip space is the window + pills, "via host", force ready, kick for all but the host, "Copy invite + address": the dialled address when joined; hosting, `inviteAddresses` + of `NetworkInterface.list` (IPv4, no loopback or link-local, private + ranges first) with the Host port, and a note that friends outside the + LAN need a forwarded port or a reachable server). Empty strip space is the window drag area; the window buttons sit at its end. Debug builds add a button that opens the layout mock (`screens/demo/`). - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the picture with a gap in the background colour (both scale with the size): green ready, amber buffering (with a spinner), grey not ready, dimmed - stale; a crown for the host (only your own app knows it hosts: the - state has no host flag) and a tower for the member passing the video - on. `memberAvatarImage` is the one picture lookup: yours from the + stale; a crown for the host (`members[].host` in the state; your own + app also knows before the first state) and a tower for the member + passing the video on. `memberAvatarImage` is the one picture lookup: yours from the profile, anyone else's from `coordinator.avatars` by `avatarHash`. Colours are local: yours is picked in the profile, others' come from their name. @@ -552,16 +556,40 @@ sideways until an end, then the page). the `connect_form` box (an old picture path is shrunk once and kept as bytes). Online, a name change (once typing settles) or picture change goes out through `Profile.push` (`coordinator.updateProfile`). -- **Tabs**: Library (`library_tab.dart`) is the media picker - (`media_picker.dart`: Jellyfin, Link, File; a `MediaPickerController` - whose `selection` is a sealed `PickedMedia`, see `picked_media.dart`) - with "Put it up for everyone" (`screens/session/swap_panel.dart`); it - starts on what was last put up. Offline it only browses. Schedule is a - placeholder. Settings (`settings_tab.dart`) has a left nav (chips under - 760 px) over the settings panels (`screens/home/settings_page.dart`): - profile, player (hardware decoding, mpv-style controls, shaders), - Jellyfin & integrations (linked servers, auto-advance), updates, - shortcuts, about. +- **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin / + Link / Files, fade through) with the Jellyfin library chips beside it. + Its state (source, link field and relay mode, selected Jellyfin card, + last pick) is the shell's `MediaPickerController` + (`screens/home/media_picker.dart`; picks are sealed `PickedMedia`, + `picked_media.dart`), so it survives tab switches; it opens on Jellyfin + with the last put-up selected. Jellyfin cards select on click and open + `JellyfinDetailsPanel` (`jellyfin/details_panel.dart`, 400 px, slides + in, docked from 1000 px library width, floating over it below; content + cross-fades between items): banner, meta, "Put it up · S1E3", mark + watched / unwatched, remove from Continue, overview, the episode list + (click picks the episode, double-click puts it up). A double-click on a + card puts it up (a series: `playableFor`, its next episode). Link + (`library_link.dart`): paste field, a preview (YouTube oEmbed title and + thumbnail, else the file name), the three relay modes online, and the + last 12 links put up (`recentLinks` in the `connect_form` box). Files + is `FilesPage` (`files/`). Ctrl+V outside a field pastes a link into + Link. Offline everything browses; the buttons say "Join or host to + watch together". Schedule (`schedule/`) is a week of day columns built + from AniList's airing schedule (`anilist.dart`: pages cached 10 min in + memory and 1 h on disk, stale data shown on 429 or failure) matched + against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; + `/library` marks shows on Jellyfin it doesn't track) into a status per + episode (`schedule_model.dart`); requests and the owner's inbox are in + `requests_sheet.dart`. It refreshes every minute; mouse drag and + Shift+wheel scroll the board sideways, the plain wheel scrolls a column. + Settings (`settings_tab.dart`) has a left nav with icons + (icon chips under 760 px) over pages of rounded Material cards + (`SettingsPanel`), one help line under every setting: profile, party & + network (Host card port and password in the connect form, recent + servers), player (the shared rows below, custom shader files), + Jellyfin & integrations (linked servers, AnimeFlow's `/me` via + `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds + (what pops up; no switches), updates, shortcuts, about. - **Player** (`session_player.dart`): `SessionPlayer` holds the one `Video` of the current player (a new `GlobalKey` per player generation). The shell keeps it in one layer of the body at one place in the element @@ -578,15 +606,28 @@ sideways until an end, then the page). is closed opens it: full right after joining or after putting it up here, else mini. While held, a pill under the picture names who everyone waits for, or says "Waiting for you · press Space" (a click - marks you ready); the mini shows it in its hover bar. + marks you ready); the mini shows it in its hover bar. ⚙ in both + control designs opens the player settings card (`controls/ + player_settings.dart`, `showPopover`): player design, progress line + when hidden, upscaling shader, hardware decoding (set live on mpv's + `hwdec`), subtitle and audio track. The rows (`playerSettingRows`) are + the same widgets Settings → Player shows; all apply live. The progress + line (2 px, `AppSettings.progressLine`) is the collapsed uosc timeline; + for the standard design `ControlsHoverProbe` mirrors media_kit's + visibility rule (up on mount and mouse movement, down 3 s later or on + exit), as media_kit does not expose it. Fullscreen adds the + `WindowButtons` to the top bar while the controls are up (restore + leaves fullscreen). - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right (strip button or Tab): chat with avatars, other activity as quiet lines. **Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with - the actor's avatar when the text starts with a member's name; each + the avatar of their `ToastItem.actor` (feed and chat toasts; the app's + own have none); each slides in, stays 5 s, then slides out and collapses its gap. -Settings persist in two Hive boxes: `connect_form` (last form, recents and -the profile picture and colour; saved even when a join fails) and +Settings persist in two Hive boxes: `connect_form` (last form, recent +servers and links, the profile picture and colour; saved even when a join +fails) and `settings`. Link media names are derived from the URL path and never contain a query string. @@ -604,14 +645,28 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): -> Quick Connect (code, poll, cancel) or password; reconnect must hit the same ServerId. `accounts_panel.dart` is the Integrations card. - `browser.dart`: back stack of `BrowseRoute`s (Home rows, library grid, - series, detail) kept alive under `Visibility(maintainState)` so back keeps - scroll and loaded pages; search (300 ms debounce) overlays them. Cards - (`cards.dart`) fill the width with whole columns (`fitColumns`); rows and - grids are lazy builders; library grids page 100 at a time with skeletons - up to the total count. Images (`images.dart`) are `Image.network` at - width steps (160..2560) with the image tag in the URL, so the image cache - key changes only when the image does. An inspector pane follows - hover/focus from 1400 px browser width. + series) kept alive under `Visibility(maintainState)` so back keeps + scroll and loaded pages; search (300 ms debounce) overlays them. Where + it is (server, stack, a data version that moves after watched-state + changes so rows reload in place, the Continue keys) is a + `BrowseController` shared with the header chips and the details panel. + Cards (`cards.dart`) fill the width with whole columns (`fitColumns`); + rows are `HScroll`s, rows and grids lazy builders; library grids page + 100 at a time with skeletons up to the total count. Images + (`images.dart`) are `Image.network` at width steps (160..2560) with the + image tag in the URL, so the image cache key changes only when the + image does. +- Home (`home_page.dart`, rules in `home_rows.dart`): one Continue row, + `mergeContinue` over Resume, NextUp and recently played: one card per + series, ordered by when it was last played, the resume item if that was + the last thing played in it, else next up; series untouched for 60 days + drop out unless nothing else is left; "Remove from Continue" clears a + resume point and hides the series (`dismissed:` in the + `jellyfin` box) until it is played again. "Latest in X" loads single + items (`groupItems=false`) and `latestCards` makes one series card per + series (its poster, "S1E1" or "N new"); with `hideReReleases` (setting, + row menu, default on) items added over 30 days after they aired are left + out. "Show all" opens the library sorted by date added, newest first. - Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId, mediaSourceId?, title, subtitle, runtime; no credentials). The library remembers the last pick in the `jellyfin` box and puts it up as a ref @@ -626,12 +681,14 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): video" opens the library on the series of the episode playing, its season selected and the episode highlighted. -Session controls: media_kit's `MaterialDesktopVideoControls` with our -bars (`shell/session_player.dart`, pieces in +Session controls (the Standard player design): media_kit's +`MaterialDesktopVideoControls` with our bars (`shell/session_player.dart`, pieces in `screens/session/player_controls.dart`); its own double-click and keys are off (its double-click pushes a fullscreen route with a second `Video`), the picture's double-click is a raw pointer observer (`DoubleClickListener`). -The top bar holds the title, Change video, chat and back-to-library. Its seek bar is off; ours +The top bar holds the title, Change video, chat and back-to-library +(fullscreen: exit, then the window buttons); the bottom bar ends with the +shader button, ⚙ and fullscreen. Its seek bar is off; ours (`ChapterSeekBar`: chapter diamonds, hover time with the chapter title) sits in the bottom bar, which is taller by the bar's height (the top bar keeps its row at the top). A drag sends no hover events, so the bar keeps @@ -640,8 +697,8 @@ private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the auto-hiding controls, so they stay up when the controls hide. -Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme; -Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s +The uosc player design (`controls/uosc/`, after mpv's uosc theme; +"Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s controls builder wraps both sets in `SessionControlsSwitch`, which follows the setting live. `UoscControls` is our own frame (no media_kit controls state): mouse movement shows it, 3 s @@ -651,12 +708,13 @@ drag and while a menu route is open, double-click toggles fullscreen. `cacheRect` (position to `buffered`) hatched (`hatchLines`), chapter diamonds on the top edge, current time and duration in boxes inside it, a hover tip with the chapter title over the hover time; hidden, it shrinks -to a 3 px line with chapter ticks. Above it a row of flat buttons: menu (≡: +to a 2 px line with chapter ticks (faded out when the progress line +setting is off). Above it a row of flat buttons: menu (≡: change video, chat & activity, reload, mark everyone ready, fullscreen, leave), subtitles and audio with track counts, a shader badge ("A HQ", opening the shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, -chapters, previous / next episode, change video, volume, fullscreen; chat +chapters, previous / next episode, change video, volume, ⚙, fullscreen; chat and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the current row highlighted) list tracks and chapters with start times. All actions are the coordinator's (seeks through `player.seek`, the user path; @@ -665,7 +723,7 @@ the same in both sets. Window chrome is frameless (`window_manager`): the party strip is the drag area and holds the min/max/close buttons (`widgets/window_buttons.dart`); -fullscreen has neither. +fullscreen has no drag area and shows the buttons in the player's top bar. ## Health reporting diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7870d8d..5e0dc3a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -92,7 +92,8 @@ Field notes: {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3, "serverTime":"","mediaId":1, "media":{name,size,duration,url?,jellyfin?}|absent, - "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?}], + "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, + "host"?}], "seekBy":"m1","seekSeq":3,"relay":true} {"t":"pong","c":1767225600000,"s":1767225600012} {"t":"feed","actor":"","action":"","target":"","reason":"","at":""} @@ -120,7 +121,8 @@ Field notes: relay the current Jellyfin media. `members[].jf` is the member's last `report.jf`, omitted when it never sent one. `members[].avatarHash` (omitted when the member has no picture) names the picture's bytes (see - "Avatars"). + "Avatars"). `members[].host` (omitted when false): the member sent the + server's `hostKey` (see `hello.hostKey`). - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart index f1ec499..29c188f 100644 --- a/packages/core/lib/src/jellyfin/client.dart +++ b/packages/core/lib/src/jellyfin/client.dart @@ -180,7 +180,8 @@ class JellyfinClient { final JellyfinHttp _http; /// Fields requested for list items: enough for cards and detail headers. - static const listFields = 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount'; + static const listFields = + 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated'; static const _imageParams = { 'enableImageTypes': 'Primary,Backdrop,Thumb,Logo', 'imageTypeLimit': '1', @@ -350,6 +351,22 @@ class JellyfinClient { .toList(); } + /// Episodes and movies the user finished, most recently played first + /// ([JellyfinUserData.lastPlayedDate] set): when each series was last + /// watched, which [resume] and [nextUp] do not tell for fully played + /// episodes. + Future> recentlyPlayed({int limit = 100}) async => (await items( + JellyfinItemQuery( + includeItemTypes: const [JellyfinItemType.episode, JellyfinItemType.movie], + recursive: true, + filters: const [JellyfinFilter.isPlayed], + sortBy: const [JellyfinSortBy.datePlayed], + descending: true, + limit: limit, + ), + )) + .items; + /// A page of library items; see [JellyfinItemQuery]. Future items(JellyfinItemQuery q) async { final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: { diff --git a/packages/core/lib/src/jellyfin/models.dart b/packages/core/lib/src/jellyfin/models.dart index 7f72c91..d41f318 100644 --- a/packages/core/lib/src/jellyfin/models.dart +++ b/packages/core/lib/src/jellyfin/models.dart @@ -198,6 +198,9 @@ class JellyfinItem { final String? overview; final int? productionYear; final DateTime? premiereDate; + /// When the item was added to the library (needs `DateCreated` in + /// `fields`; [JellyfinClient.listFields] has it). + final DateTime? dateCreated; final double? communityRating; final String? officialRating; final int? runTimeTicks; @@ -244,6 +247,7 @@ class JellyfinItem { this.overview, this.productionYear, this.premiereDate, + this.dateCreated, this.communityRating, this.officialRating, this.runTimeTicks, @@ -282,6 +286,7 @@ class JellyfinItem { overview: _str(j['Overview']), productionYear: _int(j['ProductionYear']), premiereDate: _date(j['PremiereDate']), + dateCreated: _date(j['DateCreated']), communityRating: _double(j['CommunityRating']), officialRating: _str(j['OfficialRating']), runTimeTicks: _int(j['RunTimeTicks']), diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index fb41424..e9861c9 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -490,6 +490,8 @@ class MemberView { final bool stale; /// Last `report.jf` of this member; empty when never reported. final Map jf; + /// The hosting app's own member (sent the server's host key). + final bool host; const MemberView({ required this.id, required this.name, @@ -499,6 +501,7 @@ class MemberView { required this.buffered, required this.stale, this.jf = const {}, + this.host = false, }); Map toJson() => { @@ -510,6 +513,7 @@ class MemberView { 'buffered': buffered, 'stale': stale, if (jf.isNotEmpty) 'jf': jf, + if (host) 'host': true, }; factory MemberView.fromJson(Map json) => MemberView( @@ -521,6 +525,7 @@ class MemberView { buffered: finiteDouble(json['buffered'], 'buffered'), stale: (json['stale'] as bool?) ?? false, jf: jfAccessFromJson(json['jf']) ?? const {}, + host: json['host'] == true, ); } diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index c9f5a4d..69a06e5 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -52,6 +52,8 @@ class EngineMember { double buffered = 0; /// Last reported Jellyfin access by ServerId (`report.jf`). Map jf = const {}; + /// The hosting app's own member (set by the server from the host key). + bool host = false; DateTime lastSeen; EngineMember({ required this.id, @@ -91,6 +93,7 @@ class EngineMember { buffered: buffered, stale: stale, jf: jf, + host: host, ); } diff --git a/packages/core/test/jellyfin_client_test.dart b/packages/core/test/jellyfin_client_test.dart index 436dc19..e376abc 100644 --- a/packages/core/test/jellyfin_client_test.dart +++ b/packages/core/test/jellyfin_client_test.dart @@ -164,6 +164,28 @@ void main() { expect(http.last.uri.queryParameters['parentId'], 'a656b907eb3a73532e40e44b968d0225'); }); + test('latest ungrouped and recently played: query params, dates', () async { + http.on('GET', '/Items/Latest', body: '[{"Id":"e","Type":"Episode",' + '"PremiereDate":"2010-01-01T00:00:00.0000000Z","DateCreated":"2026-10-01T12:00:00.0000000Z"}]'); + final latest = await client.latest('lib', limit: 100, groupItems: false); + expect(http.last.uri.queryParameters['groupItems'], 'false'); + expect(http.last.uri.queryParameters['fields'], contains('DateCreated')); + expect(latest.single.premiereDate, DateTime.utc(2010)); + expect(latest.single.dateCreated, DateTime.utc(2026, 10, 1, 12)); + + http.on('GET', '/Items', body: '{"Items":[{"Id":"p","Type":"Episode","SeriesId":"s",' + '"UserData":{"Played":true,"LastPlayedDate":"2026-10-07T20:00:00Z"}}],"TotalRecordCount":1}'); + final played = await client.recentlyPlayed(limit: 50); + final q = http.last.uri.queryParameters; + expect(q['filters'], 'IsPlayed'); + expect(q['sortBy'], 'DatePlayed'); + expect(q['sortOrder'], 'Descending'); + expect(q['includeItemTypes'], 'Episode,Movie'); + expect(q['recursive'], 'true'); + expect(q['limit'], '50'); + expect(played.single.userData.lastPlayedDate, DateTime.utc(2026, 10, 7, 20)); + }); + test('library page: query params and paging', () async { http.on('GET', '/Items', fixture: 'series_page'); const q = JellyfinItemQuery( diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 467a3b3..359f168 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -538,6 +538,21 @@ void main() { }); }); + test('host flag: only the marked member, on the wire as host:true', () { + join('a', 'alice'); + join('b', 'bob'); + engine.members['a']!.host = true; + final members = {for (final m in engine.broadcast().members) m.name: m}; + expect(members['alice']!.host, isTrue); + expect(members['bob']!.host, isFalse); + final json = engine.broadcast().toJson()['members'] as List; + expect((json.first as Map)['host'], isTrue); + expect((json.last as Map).containsKey('host'), isFalse); + final back = StateBroadcast.fromJson( + Map.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map)); + expect([for (final m in back.members) m.host], [true, false]); + }); + group('profile + avatars', () { // Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped. final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List.generate(60, (i) => i)]); diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index b19b373..88c4dd3 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -516,7 +516,9 @@ void mountSyncRoute( } memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar); } - session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey); + final isHost = hub.isHostKey(hello.hostKey); + session.tokens[token]!.isHost = isHost; + hub.engine.members[token]?.host = isHost; final activeToken = token; hub.sockets[activeToken] = ws; session.tokens[activeToken]!.onKick = (reason) async { diff --git a/packages/server/test/hardening_test.dart b/packages/server/test/hardening_test.dart index b97a88f..b4cb760 100644 --- a/packages/server/test/hardening_test.dart +++ b/packages/server/test/hardening_test.dart @@ -148,10 +148,27 @@ void main() { await host.ws.close(); }); + test('state marks the host member for everyone', () async { + await server.stop(); + server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk')); + await server.start(); + final host = await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk'); + final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend'); + final state = StateBroadcast.fromJson(await friend.next('state')); + final byName = {for (final m in state.members) m.name: m}; + expect(byName['host']!.host, isTrue); + expect(byName['friend']!.host, isFalse); + final raw = server.hub.stateBroadcast().toJson()['members'] as List; + expect([for (final m in raw) (m as Map)['host']], containsAll([true, null])); + await host.ws.close(); + await friend.ws.close(); + }); + test('no hostKey configured: nobody is the host', () async { final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: ''); final b = await _Peer.join('127.0.0.1', server.boundPort, 'b', hostKey: 'x'); expect(server.session.tokens.values.where((t) => t.isHost), isEmpty); + expect(server.hub.stateBroadcast().members.where((m) => m.host), isEmpty); await a.ws.close(); await b.ws.close(); }); diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart index df51db2..ee7f2c3 100644 --- a/packages/server/test/session_test.dart +++ b/packages/server/test/session_test.dart @@ -88,6 +88,12 @@ void main() { const h = MemberView( id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false); expect(MemberView.fromJson(Map.from(h.toJson())).avatarHash, 'abc'); + // host: only on the hosting app's member, absent otherwise. + expect(v.toJson().containsKey('host'), isFalse); + const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true); + expect(host.toJson()['host'], isTrue); + expect(MemberView.fromJson(Map.from(host.toJson())).host, isTrue); + expect(MemberView.fromJson(Map.from(v.toJson())).host, isFalse); expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'}); final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''}); expect(p.name, isNull);