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:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,73 +282,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
|
|||||||
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
|
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
return LayoutBuilder(builder: (context, box) {
|
||||||
children: [
|
final narrow = box.maxWidth < 640;
|
||||||
_ServerSwitcher(
|
return Row(
|
||||||
accounts: _accounts,
|
children: [
|
||||||
current: account,
|
_ServerSwitcher(
|
||||||
onSelect: _switchServer,
|
accounts: _accounts,
|
||||||
onOpenIntegrations: widget.onOpenIntegrations,
|
current: account,
|
||||||
),
|
compact: narrow,
|
||||||
const SizedBox(width: 4),
|
onSelect: _c.switchServer,
|
||||||
IconButton(
|
onOpenIntegrations: widget.onOpenIntegrations,
|
||||||
tooltip: 'Back (Esc)',
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
IconButton(
|
||||||
onPressed: _stack.length > 1 ? _back : null,
|
tooltip: 'Back (Esc)',
|
||||||
icon: const Icon(Icons.arrow_back, size: 20),
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
onPressed: stack.length > 1 ? _back : null,
|
||||||
const SizedBox(width: 4),
|
icon: const Icon(Icons.arrow_back, size: 20),
|
||||||
Expanded(child: Row(children: crumbs)),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(child: Row(children: crumbs)),
|
||||||
width: 260,
|
const SizedBox(width: 8),
|
||||||
child: TextField(
|
SizedBox(
|
||||||
key: const ValueKey('jf-search'),
|
width: narrow ? 170 : 260,
|
||||||
controller: _search,
|
child: TextField(
|
||||||
focusNode: _searchFocus,
|
key: const ValueKey('jf-search'),
|
||||||
onChanged: _onSearchChanged,
|
controller: _search,
|
||||||
onSubmitted: (v) {
|
focusNode: _searchFocus,
|
||||||
_debounce?.cancel();
|
onChanged: _onSearchChanged,
|
||||||
setState(() => _term = v.trim());
|
onSubmitted: (v) {
|
||||||
},
|
_debounce?.cancel();
|
||||||
decoration: InputDecoration(
|
setState(() => _term = v.trim());
|
||||||
isDense: true,
|
},
|
||||||
hintText: 'Search ( / )',
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(Icons.search, size: 18),
|
isDense: true,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
hintText: 'Search ( / )',
|
||||||
suffixIcon: ListenableBuilder(
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
listenable: _search,
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
builder: (context, _) => _search.text.isEmpty
|
suffixIcon: ListenableBuilder(
|
||||||
? const SizedBox.shrink()
|
listenable: _search,
|
||||||
: IconButton(
|
builder: (context, _) => _search.text.isEmpty
|
||||||
tooltip: 'Clear (Esc)',
|
? const SizedBox.shrink()
|
||||||
iconSize: 16,
|
: IconButton(
|
||||||
onPressed: () => setState(_clearSearch),
|
tooltip: 'Clear (Esc)',
|
||||||
icon: const Icon(Icons.close),
|
iconSize: 16,
|
||||||
),
|
onPressed: () => setState(_clearSearch),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Refresh',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () {
|
|
||||||
_accounts.invalidate(account.serverId);
|
|
||||||
setState(() => _refresh++);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.refresh, size: 20),
|
|
||||||
),
|
|
||||||
if (widget.onToggleExpanded != null)
|
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey('jf-expand'),
|
tooltip: 'Refresh',
|
||||||
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
|
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: widget.onToggleExpanded,
|
onPressed: () {
|
||||||
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
|
_accounts.invalidate(account.serverId);
|
||||||
|
setState(() => _refresh++);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
|
|||||||
final JellyfinAccount current;
|
final 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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,78 +164,55 @@ 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 ??
|
||||||
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
|
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
|
||||||
: cardSubtitle(item);
|
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
|
||||||
return Focus(
|
: cardSubtitle(item));
|
||||||
canRequestFocus: false,
|
return Semantics(
|
||||||
skipTraversal: true,
|
button: true,
|
||||||
onKeyEvent: (_, e) => _onKey(scope, e),
|
selected: selected,
|
||||||
child: Semantics(
|
label: title,
|
||||||
button: true,
|
child: InkWell(
|
||||||
selected: selected,
|
key: ValueKey('card-${item.id}'),
|
||||||
label: title,
|
autofocus: widget.autofocus,
|
||||||
child: InkWell(
|
borderRadius: BorderRadius.circular(10),
|
||||||
key: ValueKey('card-${item.id}'),
|
focusColor: Colors.transparent,
|
||||||
autofocus: widget.autofocus,
|
hoverColor: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
onTap: () => _tap(scope),
|
||||||
focusColor: Colors.transparent,
|
onHover: (h) => setState(() => _hover = h),
|
||||||
hoverColor: Colors.transparent,
|
onFocusChange: (f) => setState(() => _focus = f),
|
||||||
onTap: () => scope.activate(item),
|
child: Column(
|
||||||
onHover: (h) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
setState(() => _hover = h);
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (h) scope.onPeek(item);
|
children: [
|
||||||
},
|
image,
|
||||||
onFocusChange: (f) {
|
const SizedBox(height: 6),
|
||||||
setState(() => _focus = f);
|
SizedBox(
|
||||||
if (f) scope.onPeek(item);
|
height: cardTextHeight - 6,
|
||||||
},
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Text(title,
|
||||||
children: [
|
maxLines: 1,
|
||||||
image,
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(height: 6),
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
SizedBox(
|
fontWeight: FontWeight.w600,
|
||||||
height: cardTextHeight - 6,
|
color: selected ? scheme.primary : null)),
|
||||||
child: Column(
|
Text(sub,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
maxLines: 1,
|
||||||
children: [
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(title,
|
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: selected ? scheme.primary : null)),
|
|
||||||
Text(sub,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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,44 +312,38 @@ 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,
|
||||||
scrollDirection: Axis.horizontal,
|
builder: (c) => ListView.separated(
|
||||||
itemCount: widget.items.length,
|
controller: c,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
|
scrollDirection: Axis.horizontal,
|
||||||
itemBuilder: (context, i) => SizedBox(
|
itemCount: entries.length,
|
||||||
width: fit.itemWidth,
|
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
|
||||||
child: MediaCard(
|
itemBuilder: (context, i) => SizedBox(
|
||||||
item: widget.items[i],
|
width: fit.itemWidth,
|
||||||
shape: widget.shape,
|
child: MediaCard(
|
||||||
seriesFirst: widget.seriesFirst,
|
item: entries[i].item,
|
||||||
|
display: entries[i].display,
|
||||||
|
subtitle: entries[i].subtitle,
|
||||||
|
shape: widget.shape,
|
||||||
|
seriesFirst: widget.seriesFirst,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: t.titleMedium),
|
Expanded(
|
||||||
if (count != null) ...[
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
Flexible(
|
||||||
],
|
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
||||||
const Spacer(),
|
),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
?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>[];
|
||||||
|
|||||||
@@ -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,
|
||||||
return ListView(
|
builder: (context, _) {
|
||||||
key: const PageStorageKey('jf-home'),
|
final v = scope.controller.version;
|
||||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
|
return ListView(
|
||||||
children: [
|
key: const PageStorageKey('jf-home'),
|
||||||
_LibraryStrip(views: views),
|
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
|
||||||
const SizedBox(height: 20),
|
children: [
|
||||||
_Section(
|
if (scope.libraryStrip) ...[
|
||||||
key: const ValueKey('row-resume'),
|
LibraryStrip(views: views),
|
||||||
title: 'Continue watching',
|
const SizedBox(height: 20),
|
||||||
shape: CardShape.thumb,
|
] else if (views.isEmpty)
|
||||||
seriesFirst: true,
|
const _NoLibraries(),
|
||||||
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
|
_ContinueRow(version: v),
|
||||||
),
|
for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
|
||||||
_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,46 +92,116 @@ 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,
|
||||||
|
loading: const _RowSkeleton(title: title, shape: CardShape.thumb),
|
||||||
|
builder: (context, items) {
|
||||||
|
scope.controller.continueKeys = {for (final i in items) continueKey(i)};
|
||||||
|
if (items.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
key: const ValueKey('row-continue'),
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: MediaRow(
|
||||||
|
title: title,
|
||||||
|
entries: cardEntries(items),
|
||||||
|
shape: CardShape.thumb,
|
||||||
|
seriesFirst: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LatestRow extends StatelessWidget {
|
||||||
|
final JellyfinItem view;
|
||||||
|
final int version;
|
||||||
|
const _LatestRow({super.key, required this.view, required this.version});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = BrowseScope.of(context);
|
||||||
|
final title = 'Latest in ${view.name}';
|
||||||
|
final shape = view.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
|
||||||
|
return Loader<List<JellyfinItem>>(
|
||||||
|
version: version,
|
||||||
|
load: () => scope.load('latest:${view.id}', (c) => c.latest(view.id, limit: 100, groupItems: false)),
|
||||||
showError: false,
|
showError: false,
|
||||||
loading: _RowSkeleton(title: title, shape: shape),
|
loading: _RowSkeleton(title: title, shape: shape),
|
||||||
builder: (context, items) => items.isEmpty
|
builder: (context, items) => SignalBuilder(builder: (context) {
|
||||||
? const SizedBox.shrink()
|
final hide = AppSettings.instance.hideReReleases.value;
|
||||||
: Padding(
|
final cards = latestCards(items, hideReReleases: hide);
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
if (cards.isEmpty) return const SizedBox.shrink();
|
||||||
child: MediaRow(
|
return Padding(
|
||||||
title: title,
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
items: items,
|
child: MediaRow(
|
||||||
shape: shape,
|
key: ValueKey('latest-${view.id}'),
|
||||||
seriesFirst: seriesFirst,
|
title: title,
|
||||||
trailing: trailing,
|
entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)],
|
||||||
),
|
shape: shape,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
key: ValueKey('show-all-${view.id}'),
|
||||||
|
// Newest first, like the row.
|
||||||
|
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
|
||||||
|
child: const Text('Show all'),
|
||||||
|
),
|
||||||
|
PopupMenuButton<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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,122 +358,92 @@ 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,
|
color: selected
|
||||||
skipTraversal: true,
|
? scheme.primaryContainer.withValues(alpha: 0.35)
|
||||||
onKeyEvent: (_, k) {
|
: _hover || _focus
|
||||||
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
|
? scheme.surfaceContainerHigh
|
||||||
scope.onOpen(DetailRoute(e));
|
: Colors.transparent,
|
||||||
return KeyEventResult.handled;
|
shape: RoundedRectangleBorder(
|
||||||
}
|
borderRadius: BorderRadius.circular(10),
|
||||||
return KeyEventResult.ignored;
|
side: BorderSide(
|
||||||
},
|
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
|
||||||
child: Material(
|
width: 1.5),
|
||||||
color: selected
|
),
|
||||||
? scheme.primaryContainer.withValues(alpha: 0.35)
|
clipBehavior: Clip.antiAlias,
|
||||||
: _hover || _focus
|
child: InkWell(
|
||||||
? scheme.surfaceContainerHigh
|
key: ValueKey('episode-${e.id}'),
|
||||||
: Colors.transparent,
|
focusColor: Colors.transparent,
|
||||||
shape: RoundedRectangleBorder(
|
onTap: () => _tap(scope),
|
||||||
borderRadius: BorderRadius.circular(10),
|
onHover: (h) => setState(() => _hover = h),
|
||||||
side: BorderSide(
|
onFocusChange: (f) => setState(() => _focus = f),
|
||||||
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
|
child: Padding(
|
||||||
width: 1.5),
|
padding: const EdgeInsets.all(6),
|
||||||
),
|
child: Row(
|
||||||
clipBehavior: Clip.antiAlias,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: InkWell(
|
children: [
|
||||||
key: ValueKey('episode-${e.id}'),
|
SizedBox(
|
||||||
focusColor: Colors.transparent,
|
width: 176,
|
||||||
onTap: () => scope.onPick(e),
|
child: AspectRatio(
|
||||||
onHover: (h) {
|
aspectRatio: 16 / 9,
|
||||||
setState(() => _hover = h);
|
child: ClipRRect(
|
||||||
if (h) scope.onPeek(e);
|
borderRadius: BorderRadius.circular(6),
|
||||||
},
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
onFocusChange: (f) {
|
JellyfinImage(
|
||||||
setState(() => _focus = f);
|
client: scope.client,
|
||||||
if (f) scope.onPeek(e);
|
image: e.ownImage(JellyfinImageType.primary) ??
|
||||||
},
|
e.image(JellyfinImageType.thumb) ??
|
||||||
child: Padding(
|
e.image(JellyfinImageType.backdrop),
|
||||||
padding: const EdgeInsets.all(6),
|
icon: Icons.tv_outlined,
|
||||||
child: Row(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (progress > 0)
|
||||||
children: [
|
Positioned(
|
||||||
SizedBox(
|
left: 0,
|
||||||
width: 176,
|
right: 0,
|
||||||
child: AspectRatio(
|
bottom: 0,
|
||||||
aspectRatio: 16 / 9,
|
child: LinearProgressIndicator(
|
||||||
child: ClipRRect(
|
value: progress, minHeight: 3, backgroundColor: Colors.black54),
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Stack(fit: StackFit.expand, children: [
|
|
||||||
JellyfinImage(
|
|
||||||
client: scope.client,
|
|
||||||
image: e.ownImage(JellyfinImageType.primary) ??
|
|
||||||
e.image(JellyfinImageType.thumb) ??
|
|
||||||
e.image(JellyfinImageType.backdrop),
|
|
||||||
icon: Icons.tv_outlined,
|
|
||||||
),
|
),
|
||||||
if (progress > 0)
|
if (played || selected)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
top: 4,
|
||||||
right: 0,
|
right: 4,
|
||||||
bottom: 0,
|
child: CircleAvatar(
|
||||||
child: LinearProgressIndicator(
|
radius: 10,
|
||||||
value: progress, minHeight: 3, backgroundColor: Colors.black54),
|
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
|
||||||
|
child: Icon(Icons.check,
|
||||||
|
size: 13, color: selected ? scheme.onPrimary : Colors.white),
|
||||||
),
|
),
|
||||||
if (played || selected)
|
),
|
||||||
Positioned(
|
]),
|
||||||
top: 4,
|
),
|
||||||
right: 4,
|
),
|
||||||
child: CircleAvatar(
|
),
|
||||||
radius: 10,
|
const SizedBox(width: 12),
|
||||||
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
|
Expanded(
|
||||||
child: Icon(Icons.check,
|
child: Column(
|
||||||
size: 13, color: selected ? scheme.onPrimary : Colors.white),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
),
|
Text(
|
||||||
]),
|
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.titleSmall?.copyWith(
|
||||||
|
color: selected ? scheme.primary : played ? Colors.grey : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
),
|
if (e.overview != null) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Text(e.overview!,
|
||||||
child: Column(
|
maxLines: 2,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
overflow: TextOverflow.ellipsis,
|
||||||
children: [
|
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||||
Row(children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.titleSmall?.copyWith(
|
|
||||||
color: selected ? scheme.primary : played ? Colors.grey : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_hover)
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Details (I)',
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
|
|
||||||
iconSize: 18,
|
|
||||||
onPressed: () => scope.onOpen(DetailRoute(e)),
|
|
||||||
icon: const Icon(Icons.info_outline),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
|
||||||
if (e.overview != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(e.overview!,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
child: Column(
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
borderRadius: BorderRadius.circular(16),
|
||||||
children: [SectionLabel(title), child],
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HwdecSetting extends StatefulWidget {
|
/// 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
shown: _standardShown,
|
||||||
// Clicks on the bars are button clicks, not the video.
|
hideAfter: _controlsHide,
|
||||||
skipTop: _barHeight,
|
child: DoubleClickListener(
|
||||||
skipBottom: _barHeight + ChapterSeekBar.height,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
child: MaterialDesktopVideoControls(state),
|
// Clicks on the bars are button clicks, not the video.
|
||||||
|
skipTop: _barHeight,
|
||||||
|
skipBottom: _barHeight + ChapterSeekBar.height,
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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()],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
|
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||||
];
|
];
|
||||||
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,29 +20,26 @@ 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 (t.text.startsWith('$n: ')) {
|
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
|
||||||
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
if (t.text.startsWith('$n: ')) {
|
||||||
}
|
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
||||||
if (t.text.startsWith('$n ')) {
|
|
||||||
final rest = t.text.substring(n.length + 1);
|
|
||||||
final icon = rest.startsWith('joined')
|
|
||||||
? Icons.login
|
|
||||||
: rest.startsWith('left')
|
|
||||||
? Icons.logout
|
|
||||||
: rest.startsWith('kicked')
|
|
||||||
? Icons.gavel
|
|
||||||
: rest.startsWith('changed')
|
|
||||||
? Icons.swap_horiz
|
|
||||||
: Icons.info_outline;
|
|
||||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
|
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
||||||
|
final icon = rest.startsWith('joined')
|
||||||
|
? Icons.login
|
||||||
|
: rest.startsWith('left')
|
||||||
|
? Icons.logout
|
||||||
|
: rest.startsWith('kicked')
|
||||||
|
? Icons.gavel
|
||||||
|
: rest.startsWith('changed')
|
||||||
|
? Icons.swap_horiz
|
||||||
|
: Icons.info_outline;
|
||||||
|
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
await tester.pumpAndSettle();
|
for (final source in ['Link', 'Files', 'Jellyfin']) {
|
||||||
expect(tester.takeException(), isNull);
|
await tester.tap(find.byTooltip(source));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull, reason: source);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user