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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:58:12 +03:00
1 parent 99cda7ed04
commit d092ea371a
54 files changed
+4760 -1622

No files matched your search

+55 -21
View File
@@ -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 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 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, 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, 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. count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to 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 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 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 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 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 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 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 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 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 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 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 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. 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 ## Library
What to watch: Jellyfin (browse your linked servers, see below), Link What to watch, from three sources (the switch at the top; Ctrl+V outside
(paste an http(s) URL; Ctrl+V outside a field pastes it in) or File (path a field pastes a link into Link):
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. - **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 ## Dedicated server
@@ -118,13 +141,23 @@ is one, then Enter or Join. Your name and picture come from your profile
## Settings ## Settings
The Settings tab (Ctrl+,) has a page per topic: Profile; Player (hardware The Settings tab (Ctrl+,) has a page per topic, each setting with a line
decoding: auto, off or custom, the env var `SYNC_RELAY_HWDEC` overrides of help: Profile; Party & network (the Host card's default port and
it, applies on the next join or Reload video; "Experimental: mpv-style password, recent servers with remove); Player (below); Jellyfin &
controls", below; video shaders, see below); Jellyfin & integrations integrations (linked Jellyfin servers, below, the AnimeFlow connection:
(linked Jellyfin servers, below, and playing the next episode "connected as <name>" with what you may do, or why not, and playing the
automatically); Updates; Keyboard shortcuts (read-only); About (the next episode automatically); Notifications & sounds (what pops up; there
version). 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 = Keys while the player fills the window: Space = ready / unready, Tab =
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30, 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 Subtitles buttons (with the track count) list tracks as "Japanese · E-AC3
2.0 · title"; subtitles have Off. 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 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 timeline across the bottom shows the played part, the cached part ahead
(hatched), chapter starts as diamonds and the time; hovering it shows the (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 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"), track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, volume and ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
fullscreen; chat and back-to-library top right. Keys and skip buttons work and fullscreen; chat and back-to-library top right. Keys and skip buttons work
the same. the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it Skipping: during the opening a "Skip OP" button floats in the corner (it
@@ -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 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 fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
session starts with it. Settings → Player sets the default, session starts with it. The player's ⚙ and Settings → Player set it too,
HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time 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 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 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. fails to load shows a toast and switches shaders off.
@@ -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<bool> shown;
final Duration hideAfter;
final Widget child;
const ControlsHoverProbe({super.key, required this.shown, required this.hideAfter, required this.child});
@override
State<ControlsHoverProbe> createState() => _ControlsHoverProbeState();
}
class _ControlsHoverProbeState extends State<ControlsHoverProbe> {
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,
);
}
@@ -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<T> _dropdown<T>({
required Key key,
required T value,
required List<(T, String)> items,
required ValueChanged<T> onChanged,
bool expanded = false,
}) =>
DropdownButton<T>(
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<bool>(
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>? 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<ShaderPreset>(
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<ShaderQuality>(
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<void> 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<HwdecSetting> createState() => _HwdecSettingState();
}
class _HwdecSettingState extends State<HwdecSetting> {
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<HwdecMode>(
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<int>(
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<Widget> playerSettingRows({ReadonlySignal<RenderMode>? 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>? 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<void> showPlayerSettings(BuildContext context, {ReadonlySignal<RenderMode>? renderMode}) {
Future<void> open() => showPopover<void>(
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>? 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>? 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),
);
}
}
@@ -5,11 +5,12 @@ import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
// The experimental mpv-style (uosc) controls frame: a full-width timeline // The mpv-style (uosc) controls frame: a full-width timeline at the
// at the bottom with a row of flat buttons above it, a corner widget top // bottom with a row of flat buttons above it, a corner widget top right.
// right. Mouse movement shows them; after [UoscControls.hideAfter] without // Mouse movement shows them; after [UoscControls.hideAfter] without any
// any they fade out, the timeline shrinks to a thin line and the cursor // they fade out, the timeline shrinks to a 2 px line (gone when "Progress
// hides. Our own frame: no media_kit controls state to reach into. // line when hidden" is off) and the cursor hides. Our own frame: no
// media_kit controls state to reach into.
class UoscControls extends StatefulWidget { class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden. /// The timeline; [expanded] is false while the controls are hidden.
@@ -167,7 +168,19 @@ class UoscControlsState extends State<UoscControls> {
), ),
), ),
), ),
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<UoscControls> {
} }
} }
/// The session's controls: media_kit's ([material]) or, with Settings → /// The session's controls: the standard ones ([material]) or, with the
/// "Experimental: mpv-style controls", the uosc ones ([mpv]). Switches /// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// live when the setting changes. /// when the setting changes.
class SessionControlsSwitch extends StatelessWidget { class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material; final WidgetBuilder material;
final WidgetBuilder mpv; final WidgetBuilder mpv;
@@ -59,7 +59,7 @@ class UoscTimeline extends StatefulWidget {
}); });
static const height = 30.0; static const height = 30.0;
static const collapsedHeight = 3.0; static const collapsedHeight = 2.0;
@override @override
State<UoscTimeline> createState() => _UoscTimelineState(); State<UoscTimeline> createState() => _UoscTimelineState();
@@ -24,6 +24,7 @@ const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer'; const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick'; const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:'; const _keyBrowsePrefix = 'browse:';
const _keyDismissedPrefix = 'dismissed:';
/// One signed-in user on one server. /// One signed-in user on one server.
class JellyfinAccount { 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<String, DateTime> 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<void> 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 // --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch. /// Short-lived memo for browse calls, so going back does not refetch.
+183 -42
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
@@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute {
String get key => 'home'; 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 class LibraryRoute extends BrowseRoute {
final JellyfinItem folder; final JellyfinItem folder;
const LibraryRoute(this.folder); /// A `LibrarySort` name; null = by name.
final String? sort;
const LibraryRoute(this.folder, {this.sort});
@override @override
String get label => folder.name; String get label => folder.name;
@override @override
String get key => 'lib:${folder.id}'; String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
} }
final class SeriesRoute extends BrowseRoute { final class SeriesRoute extends BrowseRoute {
@@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute {
String get key => 'series:${series.id}'; 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 /// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
/// ids, names and the fields the pages start from (each page loads the /// ids, names and the fields the pages start from (each page loads the
/// full item itself). /// full item itself).
@@ -60,18 +54,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
for (final r in stack) for (final r in stack)
switch (r) { switch (r) {
HomeRoute() => {'r': 'home'}, 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) => { SeriesRoute(:final series, :final seasonId) => {
'r': 'series', 'r': 'series',
..._itemJson(series), ..._itemJson(series),
'seasonId': ?seasonId, 'seasonId': ?seasonId,
}, },
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
} }
]; ];
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when /// 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<BrowseRoute>? decodeBrowseStack(Object? raw) { List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (raw is! List) return null; if (raw is! List) return null;
final out = <BrowseRoute>[const HomeRoute()]; final out = <BrowseRoute>[const HomeRoute()];
@@ -79,11 +72,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (e is! Map) return null; if (e is! Map) return null;
final item = _itemFrom(e); final item = _itemFrom(e);
final route = switch (e['r']) { final route = switch (e['r']) {
'home' => null, 'lib' when item != null =>
'lib' when item != null => LibraryRoute(item), LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
'series' when item != null => 'series' when item != null =>
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null), SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
'item' when item != null => DetailRoute(item),
_ => null, _ => null,
}; };
if (route != null && out.every((r) => r.key != route.key)) out.add(route); 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<BrowseRoute> _stack = const [HomeRoute()];
bool _started = false;
int _version = 0;
Set<String> _continueKeys = const {};
List<BrowseRoute> get stack => _stack;
int get version => _version;
/// [continueKey]s of the cards in the Continue row now.
Set<String> 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<BrowseRoute> _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<String> 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]. /// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget { class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinAccount account; final JellyfinAccount account;
final JellyfinClient client; 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 String? selectedItemId;
final ValueChanged<JellyfinItem> onPick; /// Click: select (details panel).
final ValueChanged<BrowseRoute> onOpen; final ValueChanged<JellyfinItem> onSelect;
/// Hover / focus: the inspector pane follows it. /// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPeek; final ValueChanged<JellyfinItem> onPutUp;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
final bool libraryStrip;
const BrowseScope({ const BrowseScope({
super.key, super.key,
required this.accounts, required this.accounts,
required this.account, required this.account,
required this.client, required this.client,
required this.controller,
required this.selectedItemId, required this.selectedItemId,
required this.onPick, required this.onSelect,
required this.onOpen, required this.onPutUp,
required this.onPeek, this.libraryStrip = true,
required super.child, required super.child,
}); });
static BrowseScope of(BuildContext context) => static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!; context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
void onOpen(BrowseRoute route) => controller.open(route);
/// Cached browse call on this server. /// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) => Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client)); 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) { void activate(JellyfinItem item) {
if (isPlayable(item)) { if (isSelectable(item)) {
onPick(item); onSelect(item);
return; return;
} }
switch (item.kind) { switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season: case JellyfinItemKind.season:
final sid = item.seriesId; final sid = item.seriesId;
if (sid != null) { 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) { void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId; final sid = episode.seriesId;
if (sid == null) return; if (sid == null) return;
@@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget {
bool updateShouldNotify(BrowseScope old) => bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId || old.selectedItemId != selectedItemId ||
old.account != account || old.account != account ||
old.client != client; old.client != client ||
old.libraryStrip != libraryStrip;
} }
/// Runs [load] once (again on [retry]) and shows [builder] with the result, /// Runs [load] once (again on [retry] and when [version] changes) and
/// a spinner meanwhile, and a compact error with Retry on failure. /// 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<T> extends StatefulWidget { class Loader<T> extends StatefulWidget {
final Future<T> Function() load; final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder; final Widget Function(BuildContext context, T data) builder;
final Widget? loading; final Widget? loading;
/// When false the error collapses to nothing (optional sections). /// When false the error collapses to nothing (optional sections).
final bool showError; final bool showError;
final Object? version;
const Loader({ const Loader({
super.key, super.key,
required this.load, required this.load,
required this.builder, required this.builder,
this.loading, this.loading,
this.showError = true, this.showError = true,
this.version,
}); });
@override @override
@@ -231,22 +363,31 @@ class Loader<T> extends StatefulWidget {
class _LoaderState<T> extends State<Loader<T>> { class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load(); late Future<T> _future = widget.load();
(T,)? _last;
void _retry() => setState(() => _future = widget.load()); void _retry() => setState(() => _future = widget.load());
@override
void didUpdateWidget(Loader<T> old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = widget.load();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<T>( return FutureBuilder<T>(
future: _future, future: _future,
builder: (context, snap) { 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 (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink(); if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry); return LoadError(error: snap.error!, onRetry: _retry);
} }
if (snap.connectionState == ConnectionState.done) { final last = _last;
return widget.builder(context, snap.data as T); if (last != null) return widget.builder(context, last.$1);
}
return widget.loading ?? return widget.loading ??
const Padding( const Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(32),
+75 -123
View File
@@ -2,52 +2,51 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart'; import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart'; import 'library_page.dart';
import 'search_results.dart'; import 'search_results.dart';
import 'series_page.dart'; import 'series_page.dart';
import 'sign_in_panel.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, /// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library /// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back /// grids, series). Pages below the top stay alive, so going back keeps
/// keeps scroll position and loaded pages. /// 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 /// A click on a card selects it ([onSelect]; libraries and folders open),
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches. /// 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 { class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinMedia? selected; final BrowseController? controller;
final ValueChanged<JellyfinMedia> onPick; /// Highlighted card.
/// Shown when given: toggles a bigger browser (the host hides its form). final JellyfinSelection? selected;
final VoidCallback? onToggleExpanded; final ValueChanged<JellyfinSelection> onSelect;
final bool expanded; final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final Duration searchDebounce; final Duration searchDebounce;
/// Opens here instead of the remembered location (the series of the /// 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; final BrowseStart? start;
/// Library chips on Home (off when the Library header shows them).
final bool libraryStrip;
const JellyfinBrowser({ const JellyfinBrowser({
this.start, this.start,
super.key, super.key,
required this.accounts, required this.accounts,
required this.onPick, required this.onSelect,
required this.onPutUp,
this.controller,
this.selected, this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations, this.onOpenIntegrations,
this.libraryStrip = true,
this.searchDebounce = const Duration(milliseconds: 300), this.searchDebounce = const Duration(milliseconds: 300),
}); });
@@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget {
} }
class _JellyfinBrowserState extends State<JellyfinBrowser> { class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId; BrowseController? _own;
List<BrowseRoute> _stack = const [HomeRoute()];
final _search = TextEditingController(); final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search'); final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body'); final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = ''; String _term = '';
Timer? _debounce; Timer? _debounce;
int _refresh = 0; int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts; JellyfinAccounts get _accounts => widget.accounts;
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final start = widget.start; _c.start(start: widget.start, serverHint: widget.selected?.serverId);
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);
}
_accounts.addListener(_changed); _accounts.addListener(_changed);
} _c.addListener(_moved);
/// The remembered location on [serverId], else Home.
List<BrowseRoute> _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)));
} }
@override @override
@@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
old.accounts.removeListener(_changed); old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed); widget.accounts.addListener(_changed);
} }
final was = old.controller ?? _own;
if (was != _c) {
was?.removeListener(_moved);
_c.addListener(_moved);
}
} }
@override @override
void dispose() { void dispose() {
_accounts.removeListener(_changed); _accounts.removeListener(_changed);
_c.removeListener(_moved);
_own?.dispose();
_debounce?.cancel(); _debounce?.cancel();
_search.dispose(); _search.dispose();
_searchFocus.dispose(); _searchFocus.dispose();
_body.dispose(); _body.dispose();
_peek.dispose();
super.dispose(); super.dispose();
} }
@@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
JellyfinAccount? get _account => /// Any move (here, from the library chips or a server switch) ends a
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount; /// search.
int _depth = 0;
void _switchServer(String serverId) { String? _server;
if (serverId == _account?.serverId) return; void _moved() {
if (!mounted) return;
final moved = _c.stack.length != _depth || _c.serverId != _server;
_depth = _c.stack.length;
_server = _c.serverId;
setState(() { setState(() {
_serverId = serverId; if (moved) _clearSearch();
_stack = _restored(serverId);
_peek.value = null;
_clearSearch();
}); });
} }
JellyfinAccount? get _account => _c.account;
// ------------------------------------------------------------- navigation // ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_remember();
_body.requestFocus();
}
bool _back() { bool _back() {
if (_stack.length <= 1) return false; final ok = _c.back();
setState(() => _stack = _stack.sublist(0, _stack.length - 1)); if (ok) _body.requestFocus();
_remember(); return ok;
_body.requestFocus();
return true;
} }
void _popTo(int index) { void _popTo(int index) {
setState(() { setState(_clearSearch);
_clearSearch(); _c.popTo(index);
_stack = _stack.sublist(0, index + 1);
});
_remember();
_body.requestFocus(); _body.requestFocus();
} }
@@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_term = ''; _term = '';
} }
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) { KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored; if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance; final keys = HardwareKeyboard.instance;
@@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _page(BrowseRoute r) => switch (r) { Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(), HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder), LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
SeriesRoute(:final series, :final seasonId) => SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId), JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
}; };
@override @override
@@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
final status = _accounts.statusOf(account.serverId); final status = _accounts.statusOf(account.serverId);
final sel = widget.selected; final sel = widget.selected;
final searching = _term.isNotEmpty; final searching = _term.isNotEmpty;
final stack = _c.stack;
final pages = Stack( final pages = Stack(
children: [ children: [
for (var i = 0; i < _stack.length; i++) for (var i = 0; i < stack.length; i++)
Visibility( Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'), key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
visible: !searching && i == _stack.length - 1, visible: !searching && i == stack.length - 1,
maintainState: true, maintainState: true,
child: _page(_stack[i]), child: _page(stack[i]),
), ),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term), if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
], ],
@@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
accounts: _accounts, accounts: _accounts,
account: account, account: account,
client: client, client: client,
controller: _c,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null, selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick, onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onOpen: _open, onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
onPeek: (item) => _peek.value = item, libraryStrip: widget.libraryStrip,
child: Focus( child: Focus(
onKeyEvent: _onKey, onKeyEvent: _onKey,
child: Column( child: Column(
@@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline) if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status), _StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(child: FocusScope(node: _body, child: pages)),
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)),
],
);
}),
),
], ],
), ),
), ),
@@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _header(BuildContext context, JellyfinAccount account) { Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context); final theme = Theme.of(context);
final t = theme.textTheme; final t = theme.textTheme;
final stack = _c.stack;
final crumbs = <Widget>[]; final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) { for (var i = 0; i < stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty; final last = i == stack.length - 1 && _term.isEmpty;
if (i > 0) { if (i > 0) {
crumbs.add(const Padding( crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2), padding: EdgeInsets.symmetric(horizontal: 2),
@@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
)); ));
} }
final label = Text( final label = Text(
_stack[i].label, stack[i].label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith( style: t.titleSmall?.copyWith(
@@ -328,26 +282,28 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600)))); style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
} }
return LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 640;
return Row( return Row(
children: [ children: [
_ServerSwitcher( _ServerSwitcher(
accounts: _accounts, accounts: _accounts,
current: account, current: account,
onSelect: _switchServer, compact: narrow,
onSelect: _c.switchServer,
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
), ),
const SizedBox(width: 4),
IconButton( IconButton(
tooltip: 'Back (Esc)', tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null, onPressed: stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20), icon: const Icon(Icons.arrow_back, size: 20),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)), Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 260, width: narrow ? 170 : 260,
child: TextField( child: TextField(
key: const ValueKey('jf-search'), key: const ValueKey('jf-search'),
controller: _search, controller: _search,
@@ -385,16 +341,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
}, },
icon: const Icon(Icons.refresh, size: 20), 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',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
),
], ],
); );
});
} }
} }
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
final JellyfinAccount current; final JellyfinAccount current;
final ValueChanged<String> onSelect; final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
/// Narrow: the name is shorter.
final bool compact;
const _ServerSwitcher({ const _ServerSwitcher({
required this.accounts, required this.accounts,
required this.current, required this.current,
required this.onSelect, required this.onSelect,
this.onOpenIntegrations, this.onOpenIntegrations,
this.compact = false,
}); });
@override @override
@@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget {
StatusDot(status: accounts.statusOf(current.serverId)), StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8), const SizedBox(width: 8),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
child: Text(current.displayName, child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall), maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
), ),
+75 -77
View File
@@ -2,12 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'format.dart'; import 'format.dart';
import 'images.dart'; import 'images.dart';
import '../shell/motion.dart' show HScroll;
enum CardShape { enum CardShape {
/// 2:3 poster (movies, series, latest). /// 2:3 poster (movies, series, latest).
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
_ => Icons.movie_outlined, _ => 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 /// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and /// and one meta line. Click / Enter selects it (details panel; libraries
/// focus feed the inspector, "i" or the hover button opens the details. /// 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 { class MediaCard extends StatefulWidget {
final JellyfinItem item; final JellyfinItem item;
final JellyfinItem? display;
final String? subtitle;
final CardShape shape; final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top, /// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
/// "S2E3 · 12 min left" below. /// left" below.
final bool seriesFirst; final bool seriesFirst;
final bool autofocus; final bool autofocus;
const MediaCard({ const MediaCard({
super.key, super.key,
required this.item, required this.item,
required this.shape, required this.shape,
this.display,
this.subtitle,
this.seriesFirst = false, this.seriesFirst = false,
this.autofocus = false, this.autofocus = false,
}); });
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
class _MediaCardState extends State<MediaCard> { class _MediaCardState extends State<MediaCard> {
bool _hover = false; bool _hover = false;
bool _focus = false; bool _focus = false;
DateTime? _lastTap;
JellyfinItem get item => widget.item; JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item)); void _tap(BrowseScope scope) {
final now = DateTime.now();
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) { final last = _lastTap;
if (e is! KeyDownEvent) return KeyEventResult.ignored; _lastTap = now;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) { if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
_details(scope); _lastTap = null;
return KeyEventResult.handled; scope.onPutUp(item);
return;
} }
return KeyEventResult.ignored; scope.activate(item);
} }
@override @override
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final shown = widget.display ?? item;
final selected = scope.selectedItemId == item.id; final selected = scope.selectedItemId == item.id;
final played = item.userData.played; final played = item.userData.played;
final progress = progressOf(item); final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount; final unplayed = shown.userData.unplayedItemCount;
final ring = selected final ring = selected
? scheme.primary ? scheme.primary
: _focus : _focus
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
final image = AspectRatio( final image = AspectRatio(
aspectRatio: widget.shape.aspect, aspectRatio: widget.shape.aspect,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 160),
curve: Curves.fastEaseInToSlowEaseOut,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
border: Border.all(color: ring, width: 2), border: Border.all(color: ring, width: 3),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(7),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
JellyfinImage( JellyfinImage(
client: scope.client, client: scope.client,
image: cardImage(item, widget.shape), image: cardImage(shown, widget.shape),
icon: kindIcon(item), icon: kindIcon(shown),
), ),
if (_hover || _focus) if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)), const ColoredBox(color: Color(0x22FFFFFF)),
@@ -141,7 +154,7 @@ class _MediaCardState extends State<MediaCard> {
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary)) ? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played : played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white)) ? 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( ? _Badge(
color: scheme.primaryContainer, color: scheme.primaryContainer,
child: Text('$unplayed', child: Text('$unplayed',
@@ -151,52 +164,30 @@ class _MediaCardState extends State<MediaCard> {
color: scheme.onPrimaryContainer))) color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(), : 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 title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode final sub = widget.subtitle ??
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ') ? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item); : cardSubtitle(item));
return Focus( return Semantics(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true, button: true,
selected: selected, selected: selected,
label: title, label: title,
child: InkWell( child: InkWell(
key: ValueKey('card-${item.id}'), key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus, autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
focusColor: Colors.transparent, focusColor: Colors.transparent,
hoverColor: Colors.transparent, hoverColor: Colors.transparent,
onTap: () => scope.activate(item), onTap: () => _tap(scope),
onHover: (h) { onHover: (h) => setState(() => _hover = h),
setState(() => _hover = h); onFocusChange: (f) => setState(() => _focus = f),
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -212,7 +203,7 @@ class _MediaCardState extends State<MediaCard> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: selected ? scheme.primary : null)), color: selected ? scheme.primary : null)),
Text(sub, Text(sub,
maxLines: 1, maxLines: 1,
@@ -224,7 +215,6 @@ class _MediaCardState extends State<MediaCard> {
], ],
), ),
), ),
),
); );
} }
} }
@@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate {
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth; bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
} }
/// A titled horizontal row of cards sized to fill the width; arrows (on /// One card of a [MediaRow]: see [MediaCard].
/// hover) page through it. Lazy: only visible cards are built. typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle});
List<CardEntry> cardEntries(Iterable<JellyfinItem> 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 { class MediaRow extends StatefulWidget {
final String title; final String title;
final List<JellyfinItem> items; final List<CardEntry> entries;
final CardShape shape; final CardShape shape;
final bool seriesFirst; final bool seriesFirst;
final Widget? trailing; final Widget? trailing;
const MediaRow({ const MediaRow({
super.key, super.key,
required this.title, required this.title,
required this.items, required this.entries,
required this.shape, required this.shape,
this.seriesFirst = false, this.seriesFirst = false,
this.trailing, this.trailing,
@@ -315,47 +312,41 @@ class _MediaRowState extends State<MediaRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final entries = widget.entries;
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _hover = true), onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false), onExit: (_) => setState(() => _hover = false),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
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,
],
),
),
LayoutBuilder(builder: (context, box) { LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth); final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight; final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols; final overflow = entries.length > fit.cols;
return SizedBox( return SizedBox(
height: h, height: h,
child: Stack( child: Stack(
children: [ children: [
ListView.separated( HScroll(
controller: _scroll, controller: _scroll,
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: widget.items.length, itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap), separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox( itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth, width: fit.itemWidth,
child: MediaCard( child: MediaCard(
item: widget.items[i], item: entries[i].item,
display: entries[i].display,
subtitle: entries[i].subtitle,
shape: widget.shape, shape: widget.shape,
seriesFirst: widget.seriesFirst, seriesFirst: widget.seriesFirst,
), ),
), ),
), ),
),
if (overflow && _hover) ...[ if (overflow && _hover) ...[
_PageArrow(left: true, height: h - cardTextHeight, onTap: () => _page(-1, box.maxWidth)), _PageArrow(left: true, height: h - cardTextHeight, onTap: () => _page(-1, box.maxWidth)),
_PageArrow(left: false, height: h - cardTextHeight, onTap: () => _page(1, box.maxWidth)), _PageArrow(left: false, height: h - cardTextHeight, onTap: () => _page(1, box.maxWidth)),
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 8, top: 4), padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row( child: Row(
children: [ children: [
Text(title, style: t.titleMedium), Expanded(
child: Row(
children: [
Flexible(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (count != null) ...[ if (count != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)), Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
], ],
const Spacer(), ],
),
),
?trailing, ?trailing,
], ],
), ),
@@ -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<JellyfinItem>(
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<String>().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)),
],
),
),
],
);
}
}
@@ -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<JellyfinItem?> 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<JellyfinItem> 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<String?> 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<JellyfinDetailsPanel> createState() => _JellyfinDetailsPanelState();
}
class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
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<T> _cached<T>(String key, Future<T> 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 <JellyfinItem>[], next: null);
}
final r = await Future.wait<Object?>([
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<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = r[2] as List<JellyfinItem>?;
return (
item: item,
series: r[0] as JellyfinItem,
seasons: r[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
Future<List<JellyfinItem>> _episodes(String seriesId, String seasonId) =>
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _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<void> _change(Future<void> 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<List<JellyfinItem>>(
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<JellyfinItem>? 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<String>(
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<String>().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)),
],
),
),
],
);
}
}
@@ -33,6 +33,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) {
_ => !item.isFolder && item.mediaType == 'Video', _ => !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", ... /// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) { String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[]; final parts = <String>[];
+131 -61
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart'; import 'cards.dart';
import 'home_rows.dart';
/// Libraries that hold videos; music, books, photos and playlists are left /// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser. /// out of the browser.
@@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
_ => Icons.folder_outlined, _ => Icons.folder_outlined,
}; };
/// Start page: library shortcuts, then Continue watching, Next up and the /// The video libraries of the scope's server (cached).
/// latest additions of each library. Each row loads on its own, so a slow Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
/// library does not hold up the rest. 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 { class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key}); const JellyfinHomePage({super.key});
@@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>( return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'), key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()), load: () => videoLibraries(scope),
builder: (context, all) { builder: (context, views) => ListenableBuilder(
final views = all.where(isVideoLibrary).toList(); listenable: scope.controller,
builder: (context, _) {
final v = scope.controller.version;
return ListView( return ListView(
key: const PageStorageKey('jf-home'), key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [ children: [
_LibraryStrip(views: views), if (scope.libraryStrip) ...[
LibraryStrip(views: views),
const SizedBox(height: 20), const SizedBox(height: 20),
_Section( ] else if (views.isEmpty)
key: const ValueKey('row-resume'), const _NoLibraries(),
title: 'Continue watching', _ContinueRow(version: v),
shape: CardShape.thumb, for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
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'),
),
),
], ],
); );
}, },
),
); );
} }
} }
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<JellyfinItem> views; final List<JellyfinItem> views;
const _LibraryStrip({required this.views}); const LibraryStrip({super.key, required this.views});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
if (views.isEmpty) { if (views.isEmpty) return const _NoLibraries();
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -92,47 +92,117 @@ class _LibraryStrip extends StatelessWidget {
avatar: Icon(libraryIcon(v), size: 18), avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name), label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)), onPressed: () => scope.controller.openTop(LibraryRoute(v)),
), ),
], ],
); );
} }
} }
class _Section extends StatelessWidget { class _ContinueRow extends StatelessWidget {
final String title; final int version;
final CardShape shape; const _ContinueRow({required this.version});
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load; static const title = 'Continue';
final Widget? trailing;
const _Section({ Future<List<JellyfinItem>> _load(BrowseScope scope) async {
super.key, final lists = await Future.wait([
required this.title, scope.load('resume', (c) => c.resume(limit: 40)),
required this.shape, scope.load('nextup', (c) => c.nextUp(limit: 40)),
required this.load, // Only for ordering: a failure leaves the row as it is.
this.seriesFirst = false, scope
this.trailing, .load('played', (c) => c.recentlyPlayed(limit: 150))
}); .catchError((Object _) => const <JellyfinItem>[]),
]);
return mergeContinue(
resume: lists[0],
nextUp: lists[1],
recentlyPlayed: lists[2],
dismissed: scope.accounts.continueDismissed(scope.account.serverId),
now: scope.controller.now(),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>( return Loader<List<JellyfinItem>>(
load: load, version: version,
load: () => _load(scope),
showError: false, showError: false,
loading: _RowSkeleton(title: title, shape: shape), loading: const _RowSkeleton(title: title, shape: CardShape.thumb),
builder: (context, items) => items.isEmpty builder: (context, items) {
? const SizedBox.shrink() scope.controller.continueKeys = {for (final i in items) continueKey(i)};
: Padding( if (items.isEmpty) return const SizedBox.shrink();
return Padding(
key: const ValueKey('row-continue'),
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: MediaRow( child: MediaRow(
title: title, title: title,
items: items, 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<List<JellyfinItem>>(
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) => 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, shape: shape,
seriesFirst: seriesFirst, trailing: Row(
trailing: trailing, 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<void>(
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'),
),
],
),
],
), ),
), ),
); );
}),
);
} }
} }
@@ -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<JellyfinItem> mergeContinue({
required List<JellyfinItem> resume,
required List<JellyfinItem> nextUp,
List<JellyfinItem> recentlyPlayed = const [],
Map<String, DateTime> dismissed = const {},
required DateTime now,
Duration maxAge = continueMaxAge,
}) {
final finished = <String, DateTime>{};
for (final p in recentlyPlayed) {
final at = p.userData.lastPlayedDate;
if (at != null) finished[continueKey(p)] = _later(finished[continueKey(p)], at)!;
}
final resumeBy = <String, JellyfinItem>{};
final nextBy = <String, JellyfinItem>{};
final order = <String>[];
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<LatestCard> latestCards(List<JellyfinItem> items, {bool hideReReleases = true}) {
final kept = hideReReleases ? items.where((i) => !isReRelease(i)) : items;
final groups = <String, List<JellyfinItem>>{};
final out = <Object>[]; // 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<JellyfinItem> 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');
}
@@ -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<JellyfinItem?> 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<JellyfinItem?>(
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),
],
],
),
),
],
);
}
}
@@ -39,15 +39,18 @@ const libraryPageSize = 100;
/// pages load as they come near the viewport. /// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget { class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder; 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 @override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState(); State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
} }
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> { class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name; late LibrarySort _sort =
bool _descending = false; LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
late bool _descending = _sort.descending;
bool _unplayed = false; bool _unplayed = false;
bool _favorites = false; bool _favorites = false;
@@ -1,31 +1,32 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart'; import '../screens/home/ui.dart';
import 'accounts.dart'; import 'accounts.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'browser.dart'; import 'browser.dart';
import 'sign_in_panel.dart'; import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server /// The Library's Jellyfin source: the library browser once a server is
/// is linked, else an inline add-server panel. /// linked, else an inline add-server panel.
class JellyfinPickerSource extends StatefulWidget { class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final JellyfinMedia? selected; final BrowseController? controller;
final ValueChanged<JellyfinMedia> onPick; final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseStart? start; final BrowseStart? start;
final bool libraryStrip;
const JellyfinPickerSource({ const JellyfinPickerSource({
super.key, super.key,
this.start, this.start,
required this.accounts, required this.accounts,
required this.onPick, required this.onSelect,
required this.onPutUp,
this.controller,
this.selected, this.selected,
this.onOpenIntegrations, this.onOpenIntegrations,
this.onToggleExpanded, this.libraryStrip = true,
this.expanded = false,
}); });
@override @override
@@ -52,12 +53,13 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
return JellyfinBrowser( return JellyfinBrowser(
key: const ValueKey('media-jellyfin'), key: const ValueKey('media-jellyfin'),
accounts: a, accounts: a,
controller: widget.controller,
selected: widget.selected, selected: widget.selected,
onPick: widget.onPick, onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onOpenIntegrations: widget.onOpenIntegrations, onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.start, start: widget.start,
libraryStrip: widget.libraryStrip,
); );
}, },
); );
+36 -40
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart'; import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart'; import 'format.dart';
import 'images.dart'; import 'images.dart';
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
final scope = BrowseScope.of(context); final scope = BrowseScope.of(context);
return Loader<_SeriesData>( return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'), key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope), load: () => _load(scope),
builder: (context, d) { builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId)) final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
key: ValueKey('episodes-$seasonId'), key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id, seriesId: widget.series.id,
seasonId: seasonId, seasonId: seasonId,
version: scope.controller.version,
), ),
const SliverToBoxAdapter(child: SizedBox(height: 24)), const SliverToBoxAdapter(child: SizedBox(height: 24)),
], ],
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
Wrap(spacing: 8, runSpacing: 8, children: [ Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon( FilledButton.icon(
key: const ValueKey('series-next'), 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), icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis), label: Text(nextLabel, overflow: TextOverflow.ellipsis),
), ),
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
class _EpisodeList extends StatefulWidget { class _EpisodeList extends StatefulWidget {
final String seriesId; final String seriesId;
final String seasonId; 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 @override
State<_EpisodeList> createState() => _EpisodeListState(); State<_EpisodeList> createState() => _EpisodeListState();
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
class _EpisodeListState extends State<_EpisodeList> { class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future; Future<List<JellyfinItem>>? _future;
List<JellyfinItem>? _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}', void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId)); (c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope)))); 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) { if (eps == null) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2; const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air /// 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 { class EpisodeTile extends StatefulWidget {
final JellyfinItem episode; final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode}); const EpisodeTile({super.key, required this.episode});
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
class _EpisodeTileState extends State<EpisodeTile> { class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false; bool _focus = false;
bool _hover = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -332,17 +358,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
if (e.premiereDate != null) if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}', '${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · '); ].join(' · ');
return Focus( return Material(
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 color: selected
? scheme.primaryContainer.withValues(alpha: 0.35) ? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus : _hover || _focus
@@ -358,15 +374,9 @@ class _EpisodeTileState extends State<EpisodeTile> {
child: InkWell( child: InkWell(
key: ValueKey('episode-${e.id}'), key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent, focusColor: Colors.transparent,
onTap: () => scope.onPick(e), onTap: () => _tap(scope),
onHover: (h) { onHover: (h) => setState(() => _hover = h),
setState(() => _hover = h); onFocusChange: (f) => setState(() => _focus = f),
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding( child: Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Row( child: Row(
@@ -414,9 +424,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Text(
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}', e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -424,17 +432,6 @@ class _EpisodeTileState extends State<EpisodeTile> {
color: selected ? scheme.primary : played ? Colors.grey : null, 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)), Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[ if (e.overview != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -450,7 +447,6 @@ class _EpisodeTileState extends State<EpisodeTile> {
), ),
), ),
), ),
),
); );
} }
} }
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../update/updater.dart'; import '../../update/updater.dart';
import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
@@ -41,7 +42,8 @@ class AboutPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
return Panel( return SettingsPanel(
title: 'About',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1,34 +1,25 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart'; import '../../jellyfin/browse_scope.dart' show JellyfinSelection;
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart';
import '../../widgets/media_input.dart'; import '../../widgets/media_input.dart';
import 'picked_media.dart'; import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart'; export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts']; /// The Library's sources.
enum MediaKind { jellyfin, link, file } enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the /// State of the Library tab, owned by the shell so it survives rebuilds
/// Library tab) so it survives rebuilds and tab switches. /// 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 { class MediaPickerController extends ChangeNotifier {
MediaPickerController( MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source}) {String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl), : link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath), file = TextEditingController(text: mediaPath),
_jellyfin = jellyfin, _jellyfin = jellyfin,
_source = source ?? _selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId),
(linkUrl.isNotEmpty _source = source ?? MediaKind.jellyfin {
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
link.addListener(notifyListeners); link.addListener(notifyListeners);
file.addListener(notifyListeners); file.addListener(notifyListeners);
} }
@@ -36,8 +27,9 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link; final TextEditingController link;
final TextEditingController file; final TextEditingController file;
MediaKind _source; MediaKind _source;
String? _notice;
LinkRelay _linkRelay = LinkRelay.direct; LinkRelay _linkRelay = LinkRelay.direct;
JellyfinMedia? _jellyfin;
JellyfinSelection? _selected;
LinkRelay get linkRelay => _linkRelay; LinkRelay get linkRelay => _linkRelay;
set linkRelay(LinkRelay v) { set linkRelay(LinkRelay v) {
@@ -45,17 +37,23 @@ class MediaPickerController extends ChangeNotifier {
_linkRelay = v; _linkRelay = v;
notifyListeners(); notifyListeners();
} }
JellyfinMedia? _jellyfin;
/// The Jellyfin item picked in the browser (kept while another source /// The last Jellyfin item picked (restored from the last put-up).
/// is shown).
JellyfinMedia? get jellyfin => _jellyfin; 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) { void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media; _jellyfin = media;
_selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId);
_source = MediaKind.jellyfin; _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(); notifyListeners();
} }
@@ -63,18 +61,10 @@ class MediaPickerController extends ChangeNotifier {
set source(MediaKind v) { set source(MediaKind v) {
if (v == _source) return; if (v == _source) return;
_source = v; _source = v;
_notice = null;
notifyListeners(); notifyListeners();
} }
/// One-line hint under the fields (e.g. the file dialog failed). /// What the Link or File source holds; for Jellyfin the last pick.
String? get notice => _notice;
set notice(String? v) {
_notice = v;
notifyListeners();
}
/// What the visible source has selected; null = nothing.
PickedMedia? get selection { PickedMedia? get selection {
switch (_source) { switch (_source) {
case MediaKind.link: case MediaKind.link:
@@ -107,292 +97,3 @@ class MediaPickerController extends ChangeNotifier {
super.dispose(); 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<PickedMedia?>? 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<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
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<String?> _clipboard() async {
try {
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
} catch (_) {
return null;
}
}
Future<void> _pasteAnywhere() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.pasteText(text);
}
Future<void> _pasteLink() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.link.text = text;
}
Future<void> _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<EditableTextState>() !=
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<LinkRelay>(
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<MediaKind>(
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.',
};
@@ -2,8 +2,6 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../playback/playback_controller.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import 'ui.dart'; import 'ui.dart';
@@ -26,7 +24,8 @@ const sessionShortcuts = [
('Esc', 'Close the panel or fullscreen, else ask to leave'), ('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 { class SettingsPanel extends StatelessWidget {
final String title; final String title;
final Widget child; final Widget child;
@@ -36,76 +35,56 @@ class SettingsPanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: kGap), padding: const EdgeInsets.only(bottom: kGap),
child: Panel( 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [SectionLabel(title), child], children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
child,
],
),
), ),
), ),
); );
} }
} }
class HwdecSetting extends StatefulWidget { /// A tinted hint card at the top of a Settings page.
const HwdecSetting({super.key}); class SettingsHintCard extends StatelessWidget {
final IconData icon;
@override final String text;
State<HwdecSetting> createState() => _HwdecSettingState(); const SettingsHintCard({super.key, required this.icon, required this.text});
}
class _HwdecSettingState extends State<HwdecSetting> {
final _s = AppSettings.instance;
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
@override
void dispose() {
_custom.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SignalBuilder(builder: (context) { final scheme = Theme.of(context).colorScheme;
final mode = _s.hwdecMode.value; return Container(
return Column( margin: const EdgeInsets.only(bottom: kGap),
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [ children: [
const Text('Hardware decoding'), Icon(icon, color: scheme.primary),
const SizedBox(height: 4), const SizedBox(width: 12),
const Text( Expanded(child: Text(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<HwdecMode>(
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,
),
),
],
); );
});
} }
} }
class ShaderSettings extends StatelessWidget { /// The .glsl files of the "Custom shaders" preset, in mpv order.
const ShaderSettings({super.key}); class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key});
Future<void> _addFiles() async { Future<void> _addFiles() async {
final List<PlatformFile> files; final List<PlatformFile> files;
@@ -130,65 +109,18 @@ class ShaderSettings extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = AppSettings.instance; final s = AppSettings.instance;
final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant);
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final custom = s.customShaders.value; final custom = s.customShaders.value;
final mode = PlaybackController.lastRenderMode.value;
const hint = TextStyle(fontSize: 12, color: Colors.grey);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Row(
children: [ children: [
const Text('Shaders'), Expanded(
const SizedBox(width: 16), child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.',
DropdownButton<ShaderPreset>( style: muted),
value: preset,
onChanged: (p) {
if (p != null) s.setShaderPreset(p);
},
items: [
for (final p in ShaderPreset.values)
DropdownMenuItem(value: p, child: Text(p.label)),
],
), ),
const Spacer(),
SegmentedButton<ShaderQuality>(
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( TextButton.icon(
onPressed: _addFiles, onPressed: _addFiles,
icon: const Icon(Icons.add, size: 18), icon: const Icon(Icons.add, size: 18),
@@ -197,7 +129,7 @@ class ShaderSettings extends StatelessWidget {
], ],
), ),
if (custom.isEmpty) if (custom.isEmpty)
const Text('None picked.', style: hint) Text('None picked.', style: muted)
else else
for (final (i, path) in custom.indexed) for (final (i, path) in custom.indexed)
Row( Row(
@@ -205,15 +137,13 @@ class ShaderSettings extends StatelessWidget {
Expanded( Expanded(
child: Tooltip( child: Tooltip(
message: path, message: path,
child: Text(path.split(RegExp(r'[/\\]')).last, child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis),
overflow: TextOverflow.ellipsis),
), ),
), ),
IconButton( IconButton(
tooltip: 'Remove', tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18), icon: const Icon(Icons.close, size: 18),
onPressed: () => onPressed: () => s.setCustomShaders([...custom]..removeAt(i)),
s.setCustomShaders([...custom]..removeAt(i)),
), ),
], ],
), ),
@@ -264,8 +194,10 @@ class UpdateSettings extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
SignalBuilder( SignalBuilder(
builder: (context) => SwitchListTile( builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero, 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, value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates, 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 { class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key}); const AutoAdvanceSetting({super.key});
@@ -306,8 +218,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'), key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'), title: const Text('Play the next episode automatically'),
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; ' subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it on their screen.'), 'anyone can cancel it'),
value: s.autoAdvance.value, value: s.autoAdvance.value,
onChanged: s.setAutoAdvance, 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 { class ShortcutList extends StatelessWidget {
const ShortcutList({super.key}); const ShortcutList({super.key});
@@ -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<String?> 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<SwapPanel> createState() => _SwapPanelState();
}
class _SwapPanelState extends State<SwapPanel> {
bool _busy = false;
String? _error;
Future<void> _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'),
),
],
);
},
),
],
),
);
}
}
@@ -89,7 +89,10 @@ class ToastItem {
final int id; final int id;
final String text; final String text;
final DateTime at; 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'}; const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
@@ -705,7 +708,7 @@ class SessionCoordinator {
_toastId++; _toastId++;
toasts.value = [ toasts.value = [
...toasts.peek(), ...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); final kind = sfxForFeedAction(f.action);
@@ -609,6 +609,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onOpenIntegrations: () => _openSettings(SettingsSection.integrations), onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
onLeave: _confirmLeave, onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder, videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls,
), ),
), ),
), ),
+3 -2
View File
@@ -103,8 +103,9 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs, buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id, relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale, stale: m.stale,
// Only your own app knows it hosts: the state carries no host flag. // The state marks the host (`members[].host`); your own app also
host: self && coord.hostFile != null, // knows before the first state.
host: m.host || (self && coord.hostFile != null),
self: self, self: self,
); );
} }
@@ -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<String, Object?> 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<RecentLink> pushRecentLink(List<RecentLink> list, RecentLink link) =>
[link, ...list.where((l) => l.url != link.url)].take(maxRecentLinks).toList();
String encodeRecentLinks(List<RecentLink> list) => jsonEncode([for (final l in list) l.toJson()]);
List<RecentLink> 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<Object?> Function()? read, Future<void> Function(String value)? write})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
final links = ValueNotifier<List<RecentLink>>(const []);
Future<void>? _load;
static Future<Object?> _hiveRead() async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyRecentLinks);
static Future<void> _hiveWrite(String value) async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyRecentLinks, value);
Future<void> 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<void> 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<String> Function(Uri uri);
Future<String> 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<LinkPreview?> 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<String?> 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<LibraryLinkSource> createState() => _LibraryLinkSourceState();
}
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
MediaPickerController get _c => widget.controller;
final _previews = <String, LinkPreview?>{};
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<void> _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<void> _paste() async {
try {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
if (text != null && text.isNotEmpty) _c.link.text = text;
} catch (_) {}
}
Future<void> _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<List<RecentLink>>(
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<RecentLink> recents;
final ValueChanged<RecentLink> 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),
),
],
),
),
),
),
],
);
}),
],
);
}
}
+359 -16
View File
@@ -1,11 +1,22 @@
import 'package:flutter/material.dart'; import 'dart:async';
import 'package:sync_relay_core/core.dart' show HelloMedia;
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/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 '../jellyfin/session_media.dart';
import '../screens/home/media_picker.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). /// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is /// Throws [FormatException] with a user-facing message when the server is
@@ -39,18 +50,46 @@ Future<void> restoreLastPick(
if (hadMedia) c.source = source; if (hadMedia) c.source = source;
} }
/// Library (round 1): the media picker as the "put it up" view. Offline /// The file playing now, when it is the one put up from here last: the
/// it browses, with a hint that watching needs a server. /// server's current media (state JSON) is a plain file (no link, no
class LibraryTab extends StatelessWidget { /// Jellyfin ref) named like [lastPath].
String? nowPlayingFile(Map<String, Object?>? 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 MediaPickerController controller;
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final bool online; final bool online;
/// This app hosts the server: its files stream from here directly.
final bool isHost; final bool isHost;
final BrowseStart? jellyfinStart; final BrowseStart? jellyfinStart;
/// Puts the pick up; returns an error text, or null when sent. /// Puts the pick up; returns an error text, or null when sent.
final Future<String?> Function(PickedMedia media) onPutUp; final Future<String?> Function(PickedMedia media) onPutUp;
final VoidCallback? onPutUpDone; final VoidCallback? onPutUpDone;
final VoidCallback? onOpenIntegrations; 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<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
const LibraryTab({ const LibraryTab({
super.key, super.key,
required this.controller, required this.controller,
@@ -61,19 +100,323 @@ class LibraryTab extends StatelessWidget {
this.onPutUpDone, this.onPutUpDone,
this.jellyfinStart, this.jellyfinStart,
this.onOpenIntegrations, this.onOpenIntegrations,
this.recentLinks,
this.oembed = defaultOEmbedGet,
this.filesBuilder,
this.now = DateTime.now,
}); });
@override
State<LibraryTab> createState() => _LibraryTabState();
}
class _LibraryTabState extends State<LibraryTab> {
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<String?> _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<String?> _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<void> _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<EditableTextState>() != 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwapPanel( final source = _c.source;
controller: controller, final header = Padding(
accounts: accounts, padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
isHost: isHost, child: LayoutBuilder(builder: (context, box) {
jellyfinStart: jellyfinStart, // Narrow: icons only, so the chips keep some room.
onSwap: onPutUp, final labels = box.maxWidth >= 620;
onDone: onPutUpDone, ButtonSegment<MediaKind> seg(MediaKind k, IconData icon, String label) => ButtonSegment(
onOpenIntegrations: onOpenIntegrations, value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label);
disabledHint: online ? null : 'Join or host to watch together', return Row(
children: [
SegmentedButton<MediaKind>(
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<String?> 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<List<JellyfinItem>>(
future: accounts.cached(account.serverId, 'views', client.userViews),
builder: (context, snap) {
final views = (snap.data ?? const <JellyfinItem>[]).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)),
),
);
},
),
),
);
},
);
},
); );
} }
} }
+3 -1
View File
@@ -35,12 +35,14 @@ Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
); );
} }
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65. // 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( return FadeTransition(
opacity: CurvedAnimation( opacity: CurvedAnimation(
parent: anim, parent: anim,
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn), curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
), ),
child: c, child: ScaleTransition(scale: const AlwaysStoppedAnimation(1.0), child: c),
); );
}, },
child: child, child: child,
+121 -7
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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, /// 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<void> showMembersCard(BuildContext context) => showPopover<void>( Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context, context,
at: const Alignment(0.7, -0.84), at: const Alignment(0.7, -0.84),
child: const MembersCard(), 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<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
bool private(List<int> 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<List<String>> 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 { class MembersCard extends StatelessWidget {
const MembersCard({super.key}); /// This machine's invite addresses for a port (tests pass fixed ones).
final Future<List<String>> Function(String port) lanAddresses;
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -392,22 +428,26 @@ class MembersCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
), ),
if (address != null) ...[
const Divider(), const Divider(),
if (address != null)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: TextButton.icon( child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => onPressed: () =>
Clipboard.setData(ClipboardData(text: address)), Clipboard.setData(ClipboardData(text: address)),
icon: const Icon(Icons.copy, size: 16), 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', value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'), child: Text(m.ready ? 'Force unready' : 'Force ready'),
), ),
if (!info.self) if (!info.self && !info.host)
PopupMenuItem( PopupMenuItem(
value: 'kick', value: 'kick',
child: Text( 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<List<String>> 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<List<String>> _addresses = widget.lanAddresses(widget.port);
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return FutureBuilder<List<String>>(
future: _addresses,
builder: (context, snap) {
final list = snap.data ?? const <String>[];
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),
),
),
],
),
);
},
);
}
}
+65 -12
View File
@@ -5,6 +5,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.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/buttons.dart';
import '../controls/uosc/controls.dart'; import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.dart'; import '../controls/uosc/timeline.dart';
@@ -19,6 +21,7 @@ import '../state/session_state.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../widgets/shown_frame_probe.dart'; import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart'; import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -52,6 +55,9 @@ class SessionPlayer extends StatefulWidget {
/// Asks before leaving the server. /// Asks before leaving the server.
final VoidCallback onLeave; final VoidCallback onLeave;
final VideoViewBuilder? videoBuilder; final VideoViewBuilder? videoBuilder;
/// Window buttons in the fullscreen top bar (off in tests: they need
/// window_manager).
final bool windowControls;
const SessionPlayer({ const SessionPlayer({
super.key, super.key,
required this.mode, required this.mode,
@@ -67,6 +73,7 @@ class SessionPlayer extends StatefulWidget {
this.chatOpen = false, this.chatOpen = false,
this.unread = 0, this.unread = 0,
this.videoBuilder, this.videoBuilder,
this.windowControls = true,
}); });
@override @override
@@ -87,6 +94,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _toastSeq = 0; int _toastSeq = 0;
int _shaderNoticeSeen = 0; int _shaderNoticeSeen = 0;
bool _miniHover = false; 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; SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full; bool get _full => widget.mode == PlayerMode.full;
@@ -114,6 +124,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null; if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch(); _stopShaderWatch();
_noFocus.dispose(); _noFocus.dispose();
_standardShown.dispose();
super.dispose(); super.dispose();
} }
@@ -131,13 +142,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join) /// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
/// and applied live. A null quality keeps the saved one. /// and applied live. A null quality keeps the saved one.
void _pickShader(ShaderPreset preset, ShaderQuality? quality) { void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
_coord.markInput();
_coord.playback.applyShaders(announce: true);
}
void _toggleChat() { void _toggleChat() {
_coord.markInput(); _coord.markInput();
@@ -274,6 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_full) ...[ if (_full) ...[
_healthOverlay(), _healthOverlay(),
_waitingPill(bottom: 124), _waitingPill(bottom: 124),
_progressLine(),
_jellyfinOverlay(), _jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(), if (widget.fullscreen) _pausedParty(),
] else ] else
@@ -300,6 +306,45 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
static const _barHeight = 56.0; 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<bool>(
valueListenable: _standardShown,
builder: (context, shown, _) => shown
? const SizedBox.shrink()
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
);
}),
),
);
String get _title { String get _title {
final ref = _coord.currentRef.value; final ref = _coord.currentRef.value;
@@ -320,7 +365,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
keyboardShortcuts: {}, keyboardShortcuts: {},
visibleOnMount: true, visibleOnMount: true,
hideMouseOnControlsRemoval: true, hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(seconds: 3), controlsHoverDuration: _controlsHide,
// Our seek bar (chapter marks, hover title) sits in the bottom bar, // 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. // which grows by its height; the top bar keeps its row at the top.
displaySeekBar: false, displaySeekBar: false,
@@ -359,6 +404,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen), icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
), ),
?_fullscreenWindowButtons(),
], ],
), ),
), ),
@@ -455,6 +501,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onPick: _pickShader, onPick: _pickShader,
onMenuOpen: menuOpen, onMenuOpen: menuOpen,
), ),
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen), icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
onPressed: widget.onToggleFullscreen, onPressed: widget.onToggleFullscreen,
@@ -463,20 +510,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
} }
Widget _sessionControls(VideoState state) { Widget _sessionControls(VideoState state) {
// Settings → "Experimental: mpv-style controls" swaps them live. // The "Player design" setting swaps them live.
return SessionControlsSwitch( return SessionControlsSwitch(
material: (_) => DoubleClickListener( material: (_) => ControlsHoverProbe(
shown: _standardShown,
hideAfter: _controlsHide,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen, onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video. // Clicks on the bars are button clicks, not the video.
skipTop: _barHeight, skipTop: _barHeight,
skipBottom: _barHeight + ChapterSeekBar.height, skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state), child: MaterialDesktopVideoControls(state),
), ),
),
mpv: (_) => _mpvControls(), 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). /// all through the coordinator (seeks via `player.seek`, the user path).
Widget _mpvControls() { Widget _mpvControls() {
final coord = _coord; final coord = _coord;
@@ -504,6 +555,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)', tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse, onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
), ),
?_fullscreenWindowButtons(),
], ],
), ),
timeline: (context, expanded) => SignalBuilder( timeline: (context, expanded) => SignalBuilder(
@@ -593,6 +645,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onChanged: (x) => playback.player.setVolume(x), onChanged: (x) => playback.player.setVolume(x),
), ),
), ),
UoscPlayerSettingsButton(renderMode: playback.renderMode),
UoscButton( UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
tooltip: 'Fullscreen (F)', tooltip: 'Fullscreen (F)',
@@ -930,7 +983,7 @@ class WaitingPill extends StatelessWidget {
class _MiniSeekBar extends StatelessWidget { class _MiniSeekBar extends StatelessWidget {
final bool thin; final bool thin;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
const _MiniSeekBar({this.thin = false, this.onSeek}); const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+281 -10
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import 'avatar.dart'; import 'avatar.dart';
import 'motion.dart'; import 'motion.dart';
@@ -12,8 +15,10 @@ import 'motion.dart';
/// The Settings tab's pages, in nav order. /// The Settings tab's pages, in nav order.
enum SettingsSection { enum SettingsSection {
profile(Icons.person_outline, 'Profile'), profile(Icons.person_outline, 'Profile'),
network(Icons.hub_outlined, 'Party & network'),
player(Icons.play_circle_outline, 'Player'), player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'), integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
notifications(Icons.notifications_none, 'Notifications & sounds'),
updates(Icons.system_update_alt, 'Updates'), updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'), shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About'); about(Icons.info_outline, 'About');
@@ -23,9 +28,9 @@ enum SettingsSection {
const SettingsSection(this.icon, this.label); const SettingsSection(this.icon, this.label);
} }
/// App-wide settings under a left nav (chips on top when narrow). The /// App-wide settings under a left nav (chips on top when narrow), each
/// pages hold the existing settings panels; the shell owns [section] so /// page a column of cards; the shell owns [section] so the profile card
/// the profile card can open a page. /// can open a page.
class SettingsTab extends StatelessWidget { class SettingsTab extends StatelessWidget {
final SettingsSection section; final SettingsSection section;
final ValueChanged<SettingsSection> onSection; final ValueChanged<SettingsSection> onSection;
@@ -33,6 +38,8 @@ class SettingsTab extends StatelessWidget {
final UpdateController? updates; final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance]. /// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin; final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe;
const SettingsTab({ const SettingsTab({
super.key, super.key,
required this.section, required this.section,
@@ -40,6 +47,7 @@ class SettingsTab extends StatelessWidget {
this.onEditProfile, this.onEditProfile,
this.updates, this.updates,
this.jellyfin, this.jellyfin,
this.animeFlowMe,
}); });
List<Widget> _page(BuildContext context) { List<Widget> _page(BuildContext context) {
@@ -64,25 +72,43 @@ class SettingsTab extends StatelessWidget {
), ),
), ),
]; ];
case SettingsSection.network:
return const [HostDefaultsPanel(), RecentServersPanel()];
case SettingsSection.player: 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( SettingsPanel(
title: 'Player', title: 'Player',
child: Column( child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
), ),
), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
]; ];
case SettingsSection.integrations: case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
return [ return [
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16), 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 playback', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
]; ];
case SettingsSection.notifications:
return const [NotificationsInfo()];
case SettingsSection.updates: case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))]; return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts: case SettingsSection.shortcuts:
@@ -111,6 +137,8 @@ class SettingsTab extends StatelessWidget {
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ChoiceChip( child: ChoiceChip(
key: ValueKey('settings-${s.name}'), key: ValueKey('settings-${s.name}'),
avatar: Icon(s.icon, size: 18),
showCheckmark: false,
label: Text(s.label), label: Text(s.label),
selected: section == s, selected: section == s,
onSelected: (_) => onSection(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<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
}
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
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<void> _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<AnimeFlowMe> Function() me;
const AnimeFlowCard({super.key, required this.me});
@override
State<AnimeFlowCard> createState() => _AnimeFlowCardState();
}
class _AnimeFlowCardState extends State<AnimeFlowCard> {
late Future<AnimeFlowMe> _me = widget.me();
static String scopeText(Set<AnimeFlowScope> 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<AnimeFlowMe>(
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'),
),
],
);
}
}
+8 -17
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show MemberView;
import '../session/session_coordinator.dart' show ToastItem; import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart'; import '../state/session_state.dart';
@@ -21,16 +20,16 @@ class ShellToast {
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false}); 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 /// Splits a coordinator toast into who and what: feed and chat toasts
/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's. /// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
ShellToast shellToastFor(ToastItem t, Iterable<String> names, AvatarInfo Function(String name) avatar) { /// hi"); the rest are the app's.
final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length); ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
for (final n in sorted) { 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: ')) { 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, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
} }
if (t.text.startsWith('$n ')) { final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
final rest = t.text.substring(n.length + 1);
final icon = rest.startsWith('joined') final icon = rest.startsWith('joined')
? Icons.login ? Icons.login
: rest.startsWith('left') : rest.startsWith('left')
@@ -42,9 +41,6 @@ ShellToast shellToastFor(ToastItem t, Iterable<String> names, AvatarInfo Functio
: Icons.info_outline; : Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon); return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
} }
}
return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each /// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap. /// slides in, stays [life], then slides out and collapses its gap.
@@ -75,13 +71,8 @@ class _PartyToastsState extends State<PartyToasts> {
if (fresh.isEmpty) return; if (fresh.isEmpty) return;
// Only new toasts re-run this, not the party they mention. // Only new toasts re-run this, not the party they mention.
untracked(() { untracked(() {
final r = coord.serverState.value;
final names = [
for (final m in r?.members ?? const <MemberView>[]) m.name,
for (final f in coord.feedLog.value) f.actor,
];
for (final t in fresh) { for (final t in fresh) {
_add(shellToastFor(t, names, (n) => avatarForName(n, coord))); _add(shellToastFor(t, (n) => avatarForName(n, coord)));
} }
}); });
}); });
+24 -2
View File
@@ -18,6 +18,8 @@ const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders'; const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance'; const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls'; const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
enum HwdecMode { auto, off, custom } enum HwdecMode { auto, off, custom }
@@ -29,9 +31,15 @@ class AppSettings {
final autoCheckUpdates = signal(true); final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next. /// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true); final autoAdvance = signal(true);
/// Experimental: the session uses the mpv-style (uosc) controls /// Player design: the uosc controls (`controls/uosc/`) instead of the
/// (`controls/uosc/`) instead of media_kit's. Applies live. /// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false); 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 /// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button). /// in the session (Ctrl+0..6, the shader button).
@@ -70,6 +78,10 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance; if (advance is bool) autoAdvance.value = advance;
final mpv = box.get(_keyMpvControls); final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv; 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); final preset = box.get(_keyShaderPreset);
shaderPreset.value = shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -112,6 +124,16 @@ class AppSettings {
_put(_keyMpvControls, on); _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) { void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset; shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name); _put(_keyShaderPreset, preset.name);
@@ -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();
}
},
);
} }
+19 -15
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.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', () { test('pasting sorts links and paths into the right source', () {
final c = MediaPickerController(); final c = MediaPickerController();
expect(c.source, MediaKind.file); expect(c.source, MediaKind.jellyfin);
c.pasteText(' https://example.com/a.mkv '); c.pasteText(' https://example.com/a.mkv ');
expect(c.source, MediaKind.link); expect(c.source, MediaKind.link);
expect(c.selection, const LinkMedia('https://example.com/a.mkv')); 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 { testWidgets('tabs and settings pages switch the body', (tester) async {
await pumpShell(tester, const Size(1280, 900)); 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'))); await tester.tap(find.byKey(const ValueKey('tab-schedule')));
// The schedule shows a spinner while it loads: pump frames, not settle. // The schedule shows a spinner while it loads: pump frames, not settle.
for (var i = 0; i < 30; i++) { for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16)); 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); expect(find.byType(SchedulePage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-settings'))); await tester.tap(find.byKey(const ValueKey('tab-settings')));
for (var i = 0; i < 30; i++) { for (var i = 0; i < 30; i++) {
@@ -148,7 +149,7 @@ void main() {
expect(find.text('Hardware decoding'), findsOneWidget); expect(find.text('Hardware decoding'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-updates'))); await tester.tap(find.byKey(const ValueKey('settings-updates')));
await tester.pumpAndSettle(); 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.tap(find.byKey(const ValueKey('settings-shortcuts')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Toggle ready'), findsOneWidget); expect(find.text('Toggle ready'), findsOneWidget);
@@ -160,7 +161,7 @@ void main() {
expect(find.text('Dev build — update checks are off'), findsOneWidget); expect(find.text('Dev build — update checks are off'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-library'))); await tester.tap(find.byKey(const ValueKey('tab-library')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget); expect(find.byKey(const ValueKey('library-tab')), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -229,21 +230,20 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget); 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 pumpShell(tester, const Size(1280, 900));
await tester.tap(find.text('File')); await tester.tap(find.text('Files'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-file')), findsOneWidget); expect(find.byType(FilesPage), findsOneWidget);
expect(find.text('Browse'), findsOneWidget);
await tester.tap(find.text('Link')); await tester.tap(find.text('Link'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-link')), findsOneWidget); expect(find.text('Watch from a link'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Offline nothing goes up. // Offline nothing goes up.
expect(find.text('Join or host to watch together'), findsOneWidget); 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.tap(find.text('Jellyfin'));
await tester.pumpAndSettle(); 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); expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.text('Open Integrations')); await tester.tap(find.text('Open Integrations'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -269,14 +269,18 @@ void main() {
testWidgets('narrow window: strip, cards and library fit', (tester) async { testWidgets('narrow window: strip, cards and library fit', (tester) async {
await pumpShell(tester, const Size(480, 800)); await pumpShell(tester, const Size(480, 800));
expect(tester.takeException(), isNull); final ex = tester.takeException();
expect(ex, isNull);
await openCard(tester, 'strip-join'); await openCard(tester, 'strip-join');
expect(find.byKey(const ValueKey('join-row')), findsOneWidget); expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tapAt(const Offset(5, 790)); await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Jellyfin')); // Narrow: the sources are icons with tooltips.
for (final source in ['Link', 'Files', 'Jellyfin']) {
await tester.tap(find.byTooltip(source));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull, reason: source);
}
}); });
} }
+19 -2
View File
@@ -30,6 +30,20 @@ class FakeJellyfin implements JellyfinHttp {
bool offline = false; bool offline = false;
int meStatus = 200; 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<Object> 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() { FakeJellyfin() {
fixture('GET', '/System/Info/Public', 'pub'); fixture('GET', '/System/Info/Public', 'pub');
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true')); on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
@@ -58,6 +72,7 @@ class FakeJellyfin implements JellyfinHttp {
fixture('GET', '/Items/Latest', 'latest_shows'); fixture('GET', '/Items/Latest', 'latest_shows');
on('GET', '/Items', (uri, _) { on('GET', '/Items', (uri, _) {
final q = uri.queryParameters; final q = uri.queryParameters;
if (q['filters'] == 'IsPlayed' && q['sortBy'] == 'DatePlayed') return _page(recentlyPlayed);
if ((q['searchTerm'] ?? '').isNotEmpty) { if ((q['searchTerm'] ?? '').isNotEmpty) {
return q['searchTerm']!.toLowerCase().startsWith('ari') return q['searchTerm']!.toLowerCase().startsWith('ari')
? JellyfinHttpResponse(200, jellyfinFixture('search')) ? JellyfinHttpResponse(200, jellyfinFixture('search'))
@@ -307,10 +322,12 @@ void main() {
c.dispose(); 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'); const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin); 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', () { test('JellyfinMedia round-trips and labels', () {
+161 -111
View File
@@ -1,12 +1,16 @@
import 'dart:convert';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart'; import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/shell/library_tab.dart'; import 'package:watch_together/src/jellyfin/picker_source.dart';
import 'package:watch_together/src/state/settings.dart';
import 'jellyfin_accounts_test.dart' import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
@@ -30,49 +34,149 @@ Future<void> settle(WidgetTester tester, [int frames = 6]) async {
} }
} }
/// MediaPicker on the Jellyfin source with one linked fake server. /// Pinned "now" for the Continue row (the fixtures were captured in
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker( /// October 2026).
final fixtureNow = DateTime.utc(2026, 10, 8, 12);
/// What the browser reported: selections and put-ups.
class Picks {
final selected = <JellyfinSelection>[];
final putUp = <JellyfinSelection>[];
}
/// 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 { WidgetTester tester, Size size) async {
await setSize(tester, size); await setSize(tester, size);
// Memory store + fake transport: no real IO, so no runAsync (futures // Memory store + fake transport: no real IO, so no runAsync (futures
// made there would resume outside the test's fake clock). // made there would resume outside the test's fake clock).
final (accounts, fake) = await linkedAccounts(); final (accounts, fake) = await linkedAccounts();
final c = MediaPickerController(source: MediaKind.jellyfin); final controller = BrowseController(accounts, now: () => fixtureNow);
addTearDown(c.dispose); addTearDown(controller.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts))); 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); await settle(tester);
return (c, accounts, fake); return (picks, accounts, fake, controller);
} }
Future<void> openArifureta(WidgetTester tester) async { Future<void> openArifureta(WidgetTester tester) async {
await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta'); await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta');
await settle(tester, 10); await settle(tester, 10);
expect(find.byKey(const ValueKey('search-results')), findsOneWidget); 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 tester.tap(find.byKey(const ValueKey('card-$arifuretaId')));
await settle(tester); await settle(tester);
} }
void main() { void main() {
testWidgets('home rows: continue watching with time left, next up, latest', (tester) async { testWidgets('home: one Continue row (resume + next up per series), latest as series cards',
await pumpPicker(tester, const Size(1600, 2400)); (tester) async {
expect(find.text('Continue watching'), findsOneWidget); final (_, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 2400));
expect(find.text('Next up'), findsOneWidget); 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.text('Latest in Shows'), findsOneWidget);
expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget); expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget);
// Continue watching: series name on top, "S1E10 · N min left · title" below. final row = find.byKey(const ValueKey('row-continue'));
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets); // JoJo and Black Summoner were watched yesterday; Black Summoner's
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget); // resume point is older than its last finished episode, so its next
expect(find.text('Black Summoner'), findsWidgets); // up shows. Bumpkin (62 days) and the never-dated series drop out.
expect(find.text('Monogatari'), findsWidgets); 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); 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 { (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); 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). // Opens on the season holding next up (S2E4).
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))); final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue); expect(s2.selected, isTrue);
expect(find.text('1. Irregular'), findsOneWidget); expect(find.text('1. Irregular'), findsOneWidget);
@@ -83,37 +187,38 @@ void main() {
expect(find.text('1. Irregular'), findsNothing); expect(find.text('1. Irregular'), findsNothing);
expect(find.text('1. Haulias Assemble'), findsWidgets); 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.tap(find.text('2. Roar of Revolution').first);
await tester.pump(); await tester.pump();
final sel = c.selection; final sel = picks.selected.last;
expect(sel, isA<JellyfinMedia>());
sel as JellyfinMedia;
expect(sel.serverId, fixtureServerId); expect(sel.serverId, fixtureServerId);
expect(sel.title, 'Roar of Revolution'); expect(sel.item!.name, 'Roar of Revolution');
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2"); expect(picks.putUp, isEmpty);
expect(sel.baseUrl, 'https://example.invalid'); 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.tap(find.byKey(const ValueKey('series-next')));
await tester.pump(); await tester.pump();
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4')); expect(picks.selected.last.item!.episodeLabel, 'S2E4');
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async { 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')); final search = find.byKey(const ValueKey('jf-search'));
// Focus a card, then arrow right moves to the next card. // 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)) Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first))
.requestFocus(); .requestFocus();
await tester.pump(); await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(); await tester.pump();
final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!; final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!;
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7'); expect((ink.key as ValueKey<String>).value, 'card-b18826be994070a79f9b52bc0f66f052');
// "/" jumps to search. // "/" jumps to search.
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/'); await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
@@ -125,21 +230,22 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('search-results')), findsNothing); 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. // Into a series and back with Esc.
await openArifureta(tester); 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('series-next')), findsOneWidget);
expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget); expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('series-next')), findsNothing); expect(find.byKey(const ValueKey('series-next')), findsNothing);
expect(find.text('Continue watching'), findsOneWidget); expect(find.text('Continue'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('library grid with sort and filters', (tester) async { 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 tester.tap(find.widgetWithText(ActionChip, 'Shows'));
await settle(tester); await settle(tester);
expect(find.text('218 items'), findsOneWidget); expect(find.text('218 items'), findsOneWidget);
@@ -160,33 +266,29 @@ void main() {
expect(tester.takeException(), isNull); 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 { (tester) async {
await pumpPicker(tester, const Size(820, 700)); await pumpBrowser(tester, const Size(820, 700));
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
expect(tester.takeException(), isNull); 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)); await pumpBrowser(tester, const Size(3440, 1440));
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
// Rows fill the width with whole cards.
final wideCards = find.byWidgetPredicate((w) => final wideCards = find.byWidgetPredicate((w) =>
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-')); w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
expect(wideCards.evaluate().length, greaterThan(10)); expect(wideCards.evaluate().length, greaterThan(10));
expect(narrowCard.width, greaterThan(200)); expect(narrowCard.width, greaterThan(200));
// Hover feeds the inspector. // A Latest row is an HScroll: a mouse drag moves it sideways.
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await setSize(tester, const Size(640, 1400));
addTearDown(gesture.removePointer); await settle(tester);
await gesture.addPointer(location: Offset.zero); final row = find.byKey(const ValueKey('latest-ee75511ae395034b1e7e657707b15125'));
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first)); final list = find.descendant(of: row, matching: find.byType(Scrollable)).first;
await tester.pump(); final before = tester.state<ScrollableState>(list).position.pixels;
expect( await tester.drag(list, const Offset(-300, 0), kind: PointerDeviceKind.mouse);
find.descendant( await settle(tester, 4);
of: find.byKey(const ValueKey('jf-inspector')), expect(tester.state<ScrollableState>(list).position.pixels, greaterThan(before));
matching: find.textContaining('Rice and Thieves and Heroes')), expect(tester.takeException(), isNull);
findsOneWidget);
}); });
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops', testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
@@ -245,14 +347,12 @@ void main() {
expect(tester.takeException(), isNull); 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)); await setSize(tester, const Size(1200, 900));
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin()); final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
await accounts.ensureLoaded(validate: false); await accounts.ensureLoaded(validate: false);
final c = MediaPickerController(source: MediaKind.jellyfin); await tester.pumpWidget(app(JellyfinPickerSource(
addTearDown(c.dispose); accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {})));
await tester.pumpWidget(app(SingleChildScrollView(
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget); expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
expect(find.text('Open Integrations'), 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 { 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); accounts.markExpired(fixtureServerId);
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget); expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Reconnect'), findsOneWidget);
expect(find.byType(JellyfinBrowser), 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 = <PickedMedia>[];
Future<void> 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<ButtonStyleButton>(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);
});
} }
+2 -79
View File
@@ -6,12 +6,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.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/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.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/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.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/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf; 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'), SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
seasonId: season2Id), seasonId: season2Id),
])); ]));
final c = MediaPickerController(source: MediaKind.jellyfin); await tester.pumpWidget(app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {})));
addTearDown(c.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
await settle(tester); await settle(tester);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))); final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue); expect(s2.selected, isTrue);
@@ -516,80 +514,5 @@ void main() {
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account'))); await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
expect(link, 1); 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 = <PickedMedia>[];
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<String, dynamic>());
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<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
isTrue);
expect(find.text('Put up: ${pick.label}'), findsOneWidget);
final row = tester.widget<Material>(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');
});
}); });
} }
+242
View File
@@ -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<String> ids(List<JellyfinItem> 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<String> 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 = <RecentLink>[];
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);
});
}
+392
View File
@@ -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<Object> 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<String, dynamic>;
// 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<String, dynamic>;
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<String?> 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 = <PickedMedia>[];
final recents = RecentLinks(read: () async => null, write: (_) async {});
Harness(this.controller, this.accounts, this.fake);
}
Future<Harness> 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<void> 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<FadeTransition>(
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<ButtonStyleButton>(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<ButtonStyleButton>(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<String, dynamic>());
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<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected, isTrue);
final tile = tester.widget<Material>(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);
});
}
}
+321
View File
@@ -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<void> 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<UoscControlsState>(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<AnimatedOpacity>(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<void> pumpSettings(WidgetTester tester, SettingsSection section,
{Future<AnimeFlowMe> 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<ChoiceChip>(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<MemberView> 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);
});
});
}
+8 -4
View File
@@ -301,17 +301,21 @@ void main() {
test('toasts: feed toasts carry the actor, the rest are the app\'s', () { test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal); AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
final at = DateTime.now(); final at = DateTime.now();
final names = ['Bob', 'Bob Jr']; // The actor is exact: "Bob" is not mistaken for "Bob Jr".
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who); 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.who!.name, 'Bob Jr');
expect(chat.text, 'hi there'); expect(chat.text, 'hi there');
expect(chat.chat, isTrue); 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.who!.name, 'Bob');
expect(joined.text, 'joined'); expect(joined.text, 'joined');
expect(joined.icon, Icons.login); 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.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 { testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
+9 -7
View File
@@ -447,7 +447,7 @@ void main() {
expect(find.text('material'), findsOneWidget); 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; final s = AppSettings.instance;
s.mpvControls.value = false; s.mpvControls.value = false;
addTearDown(() => s.mpvControls.value = false); addTearDown(() => s.mpvControls.value = false);
@@ -464,12 +464,14 @@ void main() {
), ),
), ),
)); ));
final toggle = find.byKey(const ValueKey('mpv-controls')); final design = find.byKey(const ValueKey('player-design'));
expect(find.text('Experimental: mpv-style controls'), findsOneWidget); expect(find.text('Player design'), findsOneWidget);
expect(tester.widget<SwitchListTile>(toggle).value, isFalse); expect(tester.widget<DropdownButton<bool>>(design).value, isFalse);
await tester.tap(toggle); await tester.tap(design);
await tester.pump(); await tester.pumpAndSettle();
await tester.tap(find.text('uosc').last);
await tester.pumpAndSettle();
expect(s.mpvControls.value, isTrue); expect(s.mpvControls.value, isTrue);
expect(tester.widget<SwitchListTile>(toggle).value, isTrue); expect(tester.widget<DropdownButton<bool>>(design).value, isTrue);
}); });
} }
+97 -39
View File
@@ -306,7 +306,8 @@ behind a `JellyfinHttp` interface (tests inject a fake). Plans:
- `JellyfinClient`: one server + one user. Auth (Quick Connect, password, - `JellyfinClient`: one server + one user. Auth (Quick Connect, password,
`/Users/Me` check, logout), browsing (views, Resume, NextUp, Latest, paged `/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), needed), PlaybackInfo, stream URLs, MediaSegments (a 404 = none),
Playing / Progress / Stopped reports, played / unplayed / resume position. Playing / Progress / Stopped reports, played / unplayed / resume position.
- `normalizeServerUrl`, `parseJellyfinLink`: pasted addresses and item links - `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 matched with `matchTrack` against the source's streams (mapped to mpv ids
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
on the wire. on the wire.
- **Change video**: the player's "Change video" opens the Library tab - **Change video**: the player's "Change video" opens the Library tab;
(the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only) through `ServerIsolate.setFile` (host) or the relay (`swapFile`).
through `ServerIsolate.setFile` and a matching `swap`.
## Desktop UI ## 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; 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 "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 (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 drag area; the window buttons sit at its end. Debug builds add a button
that opens the layout mock (`screens/demo/`). that opens the layout mock (`screens/demo/`).
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size): picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed 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 stale; a crown for the host (`members[].host` in the state; your own
state has no host flag) and a tower for the member passing the video app also knows before the first state) and a tower for the member
on. `memberAvatarImage` is the one picture lookup: yours from the passing the video on. `memberAvatarImage` is the one picture lookup: yours from the
profile, anyone else's from `coordinator.avatars` by `avatarHash`. profile, anyone else's from `coordinator.avatars` by `avatarHash`.
Colours are local: yours is picked in the profile, others' come from Colours are local: yours is picked in the profile, others' come from
their name. 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 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 bytes). Online, a name change (once typing settles) or picture change
goes out through `Profile.push` (`coordinator.updateProfile`). goes out through `Profile.push` (`coordinator.updateProfile`).
- **Tabs**: Library (`library_tab.dart`) is the media picker - **Tabs**: Library (`library_tab.dart`): a source switch (Jellyfin /
(`media_picker.dart`: Jellyfin, Link, File; a `MediaPickerController` Link / Files, fade through) with the Jellyfin library chips beside it.
whose `selection` is a sealed `PickedMedia`, see `picked_media.dart`) Its state (source, link field and relay mode, selected Jellyfin card,
with "Put it up for everyone" (`screens/session/swap_panel.dart`); it last pick) is the shell's `MediaPickerController`
starts on what was last put up. Offline it only browses. Schedule is a (`screens/home/media_picker.dart`; picks are sealed `PickedMedia`,
placeholder. Settings (`settings_tab.dart`) has a left nav (chips under `picked_media.dart`), so it survives tab switches; it opens on Jellyfin
760 px) over the settings panels (`screens/home/settings_page.dart`): with the last put-up selected. Jellyfin cards select on click and open
profile, player (hardware decoding, mpv-style controls, shaders), `JellyfinDetailsPanel` (`jellyfin/details_panel.dart`, 400 px, slides
Jellyfin & integrations (linked servers, auto-advance), updates, in, docked from 1000 px library width, floating over it below; content
shortcuts, about. 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 - **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation). `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 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 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 here, else mini. While held, a pill under the picture names who
everyone waits for, or says "Waiting for you · press Space" (a click 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 - **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. (strip button or Tab): chat with avatars, other activity as quiet lines.
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with **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. slides in, stays 5 s, then slides out and collapses its gap.
Settings persist in two Hive boxes: `connect_form` (last form, recents and Settings persist in two Hive boxes: `connect_form` (last form, recent
the profile picture and colour; saved even when a join fails) and 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 `settings`. Link media names are derived from the URL path and never
contain a query string. 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 -> Quick Connect (code, poll, cancel) or password; reconnect must hit the
same ServerId. `accounts_panel.dart` is the Integrations card. same ServerId. `accounts_panel.dart` is the Integrations card.
- `browser.dart`: back stack of `BrowseRoute`s (Home rows, library grid, - `browser.dart`: back stack of `BrowseRoute`s (Home rows, library grid,
series, detail) kept alive under `Visibility(maintainState)` so back keeps series) kept alive under `Visibility(maintainState)` so back keeps
scroll and loaded pages; search (300 ms debounce) overlays them. Cards scroll and loaded pages; search (300 ms debounce) overlays them. Where
(`cards.dart`) fill the width with whole columns (`fitColumns`); rows and it is (server, stack, a data version that moves after watched-state
grids are lazy builders; library grids page 100 at a time with skeletons changes so rows reload in place, the Continue keys) is a
up to the total count. Images (`images.dart`) are `Image.network` at `BrowseController` shared with the header chips and the details panel.
width steps (160..2560) with the image tag in the URL, so the image cache Cards (`cards.dart`) fill the width with whole columns (`fitColumns`);
key changes only when the image does. An inspector pane follows rows are `HScroll`s, rows and grids lazy builders; library grids page
hover/focus from 1400 px browser width. 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:<serverId>` 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, - Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The library mediaSourceId?, title, subtitle, runtime; no credentials). The library
remembers the last pick in the `jellyfin` box and puts it up as a ref 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 video" opens the library on the series of the episode playing, its
season selected and the episode highlighted. season selected and the episode highlighted.
Session controls: media_kit's `MaterialDesktopVideoControls` with our Session controls (the Standard player design): media_kit's
bars (`shell/session_player.dart`, pieces in `MaterialDesktopVideoControls` with our bars (`shell/session_player.dart`, pieces in
`screens/session/player_controls.dart`); its own double-click and keys are `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 off (its double-click pushes a fullscreen route with a second `Video`), the
picture's double-click is a raw pointer observer (`DoubleClickListener`). 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) (`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 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 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 1:30, Next episode) and cards live outside the auto-hiding controls, so
they stay up when the controls hide. they stay up when the controls hide.
Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme; The uosc player design (`controls/uosc/`, after mpv's uosc theme;
Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s "Player design" = `AppSettings.mpvControls`, default Standard): the `Video`'s
controls builder wraps both sets in `SessionControlsSwitch`, which follows controls builder wraps both sets in `SessionControlsSwitch`, which follows
the setting live. `UoscControls` is our the setting live. `UoscControls` is our
own frame (no media_kit controls state): mouse movement shows it, 3 s 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 `cacheRect` (position to `buffered`) hatched (`hatchLines`), chapter
diamonds on the top edge, current time and duration in boxes inside it, a 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 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, change video, chat & activity, reload, mark everyone ready, fullscreen,
leave), leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, 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 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 current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path; 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 Window chrome is frameless (`window_manager`): the party strip is the drag
area and holds the min/max/close buttons (`widgets/window_buttons.dart`); 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 ## Health reporting
+4 -2
View File
@@ -92,7 +92,8 @@ Field notes:
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3, {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?}|absent, "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} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
@@ -120,7 +121,8 @@ Field notes:
relay the current Jellyfin media. `members[].jf` is the member's last relay the current Jellyfin media. `members[].jf` is the member's last
`report.jf`, omitted when it never sent one. `members[].avatarHash` `report.jf`, omitted when it never sent one. `members[].avatarHash`
(omitted when the member has no picture) names the picture's bytes (see (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 - `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 (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
+18 -1
View File
@@ -180,7 +180,8 @@ class JellyfinClient {
final JellyfinHttp _http; final JellyfinHttp _http;
/// Fields requested for list items: enough for cards and detail headers. /// 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 = { static const _imageParams = {
'enableImageTypes': 'Primary,Backdrop,Thumb,Logo', 'enableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'imageTypeLimit': '1', 'imageTypeLimit': '1',
@@ -350,6 +351,22 @@ class JellyfinClient {
.toList(); .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<List<JellyfinItem>> 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]. /// A page of library items; see [JellyfinItemQuery].
Future<JellyfinItemPage> items(JellyfinItemQuery q) async { Future<JellyfinItemPage> items(JellyfinItemQuery q) async {
final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: { final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: {
@@ -198,6 +198,9 @@ class JellyfinItem {
final String? overview; final String? overview;
final int? productionYear; final int? productionYear;
final DateTime? premiereDate; 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 double? communityRating;
final String? officialRating; final String? officialRating;
final int? runTimeTicks; final int? runTimeTicks;
@@ -244,6 +247,7 @@ class JellyfinItem {
this.overview, this.overview,
this.productionYear, this.productionYear,
this.premiereDate, this.premiereDate,
this.dateCreated,
this.communityRating, this.communityRating,
this.officialRating, this.officialRating,
this.runTimeTicks, this.runTimeTicks,
@@ -282,6 +286,7 @@ class JellyfinItem {
overview: _str(j['Overview']), overview: _str(j['Overview']),
productionYear: _int(j['ProductionYear']), productionYear: _int(j['ProductionYear']),
premiereDate: _date(j['PremiereDate']), premiereDate: _date(j['PremiereDate']),
dateCreated: _date(j['DateCreated']),
communityRating: _double(j['CommunityRating']), communityRating: _double(j['CommunityRating']),
officialRating: _str(j['OfficialRating']), officialRating: _str(j['OfficialRating']),
runTimeTicks: _int(j['RunTimeTicks']), runTimeTicks: _int(j['RunTimeTicks']),
+5
View File
@@ -490,6 +490,8 @@ class MemberView {
final bool stale; final bool stale;
/// Last `report.jf` of this member; empty when never reported. /// Last `report.jf` of this member; empty when never reported.
final Map<String, bool> jf; final Map<String, bool> jf;
/// The hosting app's own member (sent the server's host key).
final bool host;
const MemberView({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
@@ -499,6 +501,7 @@ class MemberView {
required this.buffered, required this.buffered,
required this.stale, required this.stale,
this.jf = const {}, this.jf = const {},
this.host = false,
}); });
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@@ -510,6 +513,7 @@ class MemberView {
'buffered': buffered, 'buffered': buffered,
'stale': stale, 'stale': stale,
if (jf.isNotEmpty) 'jf': jf, if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
}; };
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -521,6 +525,7 @@ class MemberView {
buffered: finiteDouble(json['buffered'], 'buffered'), buffered: finiteDouble(json['buffered'], 'buffered'),
stale: (json['stale'] as bool?) ?? false, stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {}, jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
); );
} }
+3
View File
@@ -52,6 +52,8 @@ class EngineMember {
double buffered = 0; double buffered = 0;
/// Last reported Jellyfin access by ServerId (`report.jf`). /// Last reported Jellyfin access by ServerId (`report.jf`).
Map<String, bool> jf = const {}; Map<String, bool> jf = const {};
/// The hosting app's own member (set by the server from the host key).
bool host = false;
DateTime lastSeen; DateTime lastSeen;
EngineMember({ EngineMember({
required this.id, required this.id,
@@ -91,6 +93,7 @@ class EngineMember {
buffered: buffered, buffered: buffered,
stale: stale, stale: stale,
jf: jf, jf: jf,
host: host,
); );
} }
@@ -164,6 +164,28 @@ void main() {
expect(http.last.uri.queryParameters['parentId'], 'a656b907eb3a73532e40e44b968d0225'); 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 { test('library page: query params and paging', () async {
http.on('GET', '/Items', fixture: 'series_page'); http.on('GET', '/Items', fixture: 'series_page');
const q = JellyfinItemQuery( const q = JellyfinItemQuery(
+15
View File
@@ -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<String, Object?>.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map));
expect([for (final m in back.members) m.host], [true, false]);
});
group('profile + avatars', () { group('profile + avatars', () {
// Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped. // Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped.
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]); final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]);
+3 -1
View File
@@ -516,7 +516,9 @@ void mountSyncRoute(
} }
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar); 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; final activeToken = token;
hub.sockets[activeToken] = ws; hub.sockets[activeToken] = ws;
session.tokens[activeToken]!.onKick = (reason) async { session.tokens[activeToken]!.onKick = (reason) async {
+17
View File
@@ -148,10 +148,27 @@ void main() {
await host.ws.close(); 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 { test('no hostKey configured: nobody is the host', () async {
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: ''); 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'); 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.session.tokens.values.where((t) => t.isHost), isEmpty);
expect(server.hub.stateBroadcast().members.where((m) => m.host), isEmpty);
await a.ws.close(); await a.ws.close();
await b.ws.close(); await b.ws.close();
}); });
+6
View File
@@ -88,6 +88,12 @@ void main() {
const h = MemberView( const h = MemberView(
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false); id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc'); expect(MemberView.fromJson(Map<String, Object?>.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<String, Object?>.from(host.toJson())).host, isTrue);
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'}); expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''}); final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
expect(p.name, isNull); expect(p.name, isNull);