1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag

Library: source switch, selectable cards with a details panel, one merged
Continue row, Latest grouped per series without re-releases, Link page with
previews and recent links, Files embedded. Player: gear popover in both
designs, progress line while controls hide, live hwdec. Settings rebuilt as
cards. State marks the host member (proto stays 9, unreleased); hosting
shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps
the outgoing view's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:58:12 +03:00
1 parent 99cda7ed04
commit d092ea371a
54 files changed
+4963 -1825

No files matched your search

@@ -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';
// The experimental mpv-style (uosc) controls frame: a full-width timeline
// at the bottom with a row of flat buttons above it, a corner widget top
// right. Mouse movement shows them; after [UoscControls.hideAfter] without
// any they fade out, the timeline shrinks to a thin line and the cursor
// hides. Our own frame: no media_kit controls state to reach into.
// The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, a corner widget top right.
// Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
// line when hidden" is off) and the cursor hides. Our own frame: no
// media_kit controls state to reach into.
class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden.
@@ -167,7 +168,19 @@ class UoscControlsState extends State<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 →
/// "Experimental: mpv-style controls", the uosc ones ([mpv]). Switches
/// live when the setting changes.
/// The session's controls: the standard ones ([material]) or, with the
/// "Player design" setting on uosc, the uosc ones ([mpv]). Switches live
/// when the setting changes.
class SessionControlsSwitch extends StatelessWidget {
final WidgetBuilder material;
final WidgetBuilder mpv;
@@ -59,7 +59,7 @@ class UoscTimeline extends StatefulWidget {
});
static const height = 30.0;
static const collapsedHeight = 3.0;
static const collapsedHeight = 2.0;
@override
State<UoscTimeline> createState() => _UoscTimelineState();
@@ -24,6 +24,7 @@ const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:';
const _keyDismissedPrefix = 'dismissed:';
/// One signed-in user on one server.
class JellyfinAccount {
@@ -484,6 +485,38 @@ class JellyfinAccounts extends ChangeNotifier {
}
}
/// Continue-row entries removed by hand on [serverId] (key -> when), so a
/// next-up series (which Jellyfin cannot drop) stays out until it is
/// played again. Keys are `continueKey`s.
Map<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
/// Short-lived memo for browse calls, so going back does not refetch.
+183 -42
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
@@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute {
String get key => 'home';
}
/// A library or folder grid.
/// A library or folder grid, optionally opened with a sort ("Show all"
/// next to Latest opens it newest first).
final class LibraryRoute extends BrowseRoute {
final JellyfinItem folder;
const LibraryRoute(this.folder);
/// A `LibrarySort` name; null = by name.
final String? sort;
const LibraryRoute(this.folder, {this.sort});
@override
String get label => folder.name;
@override
String get key => 'lib:${folder.id}';
String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
}
final class SeriesRoute extends BrowseRoute {
@@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute {
String get key => 'series:${series.id}';
}
/// Movie / episode / video detail.
final class DetailRoute extends BrowseRoute {
final JellyfinItem item;
const DetailRoute(this.item);
@override
String get label => item.kind == JellyfinItemKind.episode
? (item.episodeLabel ?? item.name)
: item.name;
@override
String get key => 'item:${item.id}';
}
/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
/// ids, names and the fields the pages start from (each page loads the
/// full item itself).
@@ -60,18 +54,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
for (final r in stack)
switch (r) {
HomeRoute() => {'r': 'home'},
LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)},
LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort},
SeriesRoute(:final series, :final seasonId) => {
'r': 'series',
..._itemJson(series),
'seasonId': ?seasonId,
},
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
}
];
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when
/// [raw] is not a stack.
/// [raw] is not a stack. Entries it no longer knows are skipped.
List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (raw is! List) return null;
final out = <BrowseRoute>[const HomeRoute()];
@@ -79,11 +72,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (e is! Map) return null;
final item = _itemFrom(e);
final route = switch (e['r']) {
'home' => null,
'lib' when item != null => LibraryRoute(item),
'lib' when item != null =>
LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
'series' when item != null =>
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
'item' when item != null => DetailRoute(item),
_ => null,
};
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
@@ -141,46 +133,181 @@ class BrowseStart {
}
}
/// The card selected in the library (details panel): an item on a server.
/// [item] is what the card had, when known (the panel loads it in full).
class JellyfinSelection {
final String serverId;
final String itemId;
final JellyfinItem? item;
const JellyfinSelection(this.serverId, this.itemId, [this.item]);
@override
bool operator ==(Object other) =>
other is JellyfinSelection && other.serverId == serverId && other.itemId == itemId;
@override
int get hashCode => Object.hash(serverId, itemId);
}
/// Browser location shared by the browser, the library chips in the
/// Library header and the details panel: the server shown, its route
/// stack (saved per server on every move), a data version that bumps after
/// watched-state changes (rows reload), and the keys in the Continue row.
class BrowseController extends ChangeNotifier {
final JellyfinAccounts accounts;
/// Clock for the Continue row's age cut-off (tests pin it).
final DateTime Function() now;
BrowseController(this.accounts, {this.now = DateTime.now});
String? _serverId;
List<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].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinClient client;
/// Item id of the current pick on this server, highlighted everywhere.
final BrowseController controller;
/// Item id of the selected card on this server, highlighted everywhere.
final String? selectedItemId;
final ValueChanged<JellyfinItem> onPick;
final ValueChanged<BrowseRoute> onOpen;
/// Hover / focus: the inspector pane follows it.
final ValueChanged<JellyfinItem> onPeek;
/// Click: select (details panel).
final ValueChanged<JellyfinItem> onSelect;
/// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPutUp;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
final bool libraryStrip;
const BrowseScope({
super.key,
required this.accounts,
required this.account,
required this.client,
required this.controller,
required this.selectedItemId,
required this.onPick,
required this.onOpen,
required this.onPeek,
required this.onSelect,
required this.onPutUp,
this.libraryStrip = true,
required super.child,
});
static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
void onOpen(BrowseRoute route) => controller.open(route);
/// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client));
/// Click / Enter on a card: playable items are picked, containers open.
/// Click / Enter on a card: series, movies and episodes are selected,
/// seasons open their series, libraries and folders open.
void activate(JellyfinItem item) {
if (isPlayable(item)) {
onPick(item);
if (isSelectable(item)) {
onSelect(item);
return;
}
switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season:
final sid = item.seriesId;
if (sid != null) {
@@ -193,7 +320,7 @@ class BrowseScope extends InheritedWidget {
}
}
/// Opens the series of an episode (or the item itself if it has none).
/// Opens the series of an episode.
void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId;
if (sid == null) return;
@@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget {
bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId ||
old.account != account ||
old.client != client;
old.client != client ||
old.libraryStrip != libraryStrip;
}
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
/// a spinner meanwhile, and a compact error with Retry on failure.
/// Runs [load] once (again on [retry] and when [version] changes) and
/// shows [builder] with the result, a spinner meanwhile, and a compact
/// error with Retry on failure. A reload for a new [version] keeps the old
/// result on screen until the new one arrives.
class Loader<T> extends StatefulWidget {
final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder;
final Widget? loading;
/// When false the error collapses to nothing (optional sections).
final bool showError;
final Object? version;
const Loader({
super.key,
required this.load,
required this.builder,
this.loading,
this.showError = true,
this.version,
});
@override
@@ -231,22 +363,31 @@ class Loader<T> extends StatefulWidget {
class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load();
(T,)? _last;
void _retry() => setState(() => _future = widget.load());
@override
void didUpdateWidget(Loader<T> old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = widget.load();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: _future,
builder: (context, snap) {
if (snap.hasData) return widget.builder(context, snap.data as T);
if (snap.hasData || (snap.connectionState == ConnectionState.done && !snap.hasError)) {
_last = (snap.data as T,);
return widget.builder(context, snap.data as T);
}
if (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry);
}
if (snap.connectionState == ConnectionState.done) {
return widget.builder(context, snap.data as T);
}
final last = _last;
if (last != null) return widget.builder(context, last.$1);
return widget.loading ??
const Padding(
padding: EdgeInsets.all(32),
+124 -172
View File
@@ -2,52 +2,51 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart';
import 'search_results.dart';
import 'series_page.dart';
import 'sign_in_panel.dart';
/// Browser width from which the inspector pane shows on the right.
const inspectorFrom = 1400.0;
const inspectorWidth = 340.0;
/// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back
/// keeps scroll position and loaded pages.
/// grids, series). Pages below the top stay alive, so going back keeps
/// scroll position and loaded pages. Where it is lives in a
/// [BrowseController] (shared with the Library header's library chips and
/// the details panel; one of its own when none is given).
///
/// Keyboard: arrows move between cards, Enter picks / opens, I opens
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
/// A click on a card selects it ([onSelect]; libraries and folders open),
/// a double-click puts it up ([onPutUp]).
///
/// Keyboard: arrows move between cards, Enter selects / opens, Esc /
/// Backspace / Alt+Left go back, / or Ctrl+F searches.
class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
/// Shown when given: toggles a bigger browser (the host hides its form).
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseController? controller;
/// Highlighted card.
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final Duration searchDebounce;
/// Opens here instead of the remembered location (the series of the
/// episode playing, in the session's swap panel).
/// episode playing, after "Change video").
final BrowseStart? start;
/// Library chips on Home (off when the Library header shows them).
final bool libraryStrip;
const JellyfinBrowser({
this.start,
super.key,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations,
this.libraryStrip = true,
this.searchDebounce = const Duration(milliseconds: 300),
});
@@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget {
}
class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
BrowseController? _own;
final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = '';
Timer? _debounce;
int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts;
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
@override
void initState() {
super.initState();
final start = widget.start;
if (start != null && _accounts.account(start.serverId) != null) {
_serverId = start.serverId;
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
_remember();
} else {
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
? widget.selected!.serverId
: _accounts.defaultServerId;
_stack = _restored(_serverId);
}
_c.start(start: widget.start, serverHint: widget.selected?.serverId);
_accounts.addListener(_changed);
}
/// The remembered location on [serverId], else Home.
List<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)));
_c.addListener(_moved);
}
@override
@@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed);
}
final was = old.controller ?? _own;
if (was != _c) {
was?.removeListener(_moved);
_c.addListener(_moved);
}
}
@override
void dispose() {
_accounts.removeListener(_changed);
_c.removeListener(_moved);
_own?.dispose();
_debounce?.cancel();
_search.dispose();
_searchFocus.dispose();
_body.dispose();
_peek.dispose();
super.dispose();
}
@@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (mounted) setState(() {});
}
JellyfinAccount? get _account =>
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
void _switchServer(String serverId) {
if (serverId == _account?.serverId) return;
/// Any move (here, from the library chips or a server switch) ends a
/// search.
int _depth = 0;
String? _server;
void _moved() {
if (!mounted) return;
final moved = _c.stack.length != _depth || _c.serverId != _server;
_depth = _c.stack.length;
_server = _c.serverId;
setState(() {
_serverId = serverId;
_stack = _restored(serverId);
_peek.value = null;
_clearSearch();
if (moved) _clearSearch();
});
}
JellyfinAccount? get _account => _c.account;
// ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_remember();
_body.requestFocus();
}
bool _back() {
if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_remember();
_body.requestFocus();
return true;
final ok = _c.back();
if (ok) _body.requestFocus();
return ok;
}
void _popTo(int index) {
setState(() {
_clearSearch();
_stack = _stack.sublist(0, index + 1);
});
_remember();
setState(_clearSearch);
_c.popTo(index);
_body.requestFocus();
}
@@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_term = '';
}
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
@@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
};
@override
@@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
final status = _accounts.statusOf(account.serverId);
final sel = widget.selected;
final searching = _term.isNotEmpty;
final stack = _c.stack;
final pages = Stack(
children: [
for (var i = 0; i < _stack.length; i++)
for (var i = 0; i < stack.length; i++)
Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
visible: !searching && i == _stack.length - 1,
key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
visible: !searching && i == stack.length - 1,
maintainState: true,
child: _page(_stack[i]),
child: _page(stack[i]),
),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
],
@@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
accounts: _accounts,
account: account,
client: client,
controller: _c,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick,
onOpen: _open,
onPeek: (item) => _peek.value = item,
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
libraryStrip: widget.libraryStrip,
child: Focus(
onKeyEvent: _onKey,
child: Column(
@@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(builder: (context, box) {
final inspector = box.maxWidth >= inspectorFrom;
final body = FocusScope(node: _body, child: pages);
if (!inspector) return body;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: body),
const SizedBox(width: 16),
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
],
);
}),
),
Expanded(child: FocusScope(node: _body, child: pages)),
],
),
),
@@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context);
final t = theme.textTheme;
final stack = _c.stack;
final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty;
for (var i = 0; i < stack.length; i++) {
final last = i == stack.length - 1 && _term.isEmpty;
if (i > 0) {
crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
@@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
));
}
final label = Text(
_stack[i].label,
stack[i].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
@@ -328,73 +282,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
}
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
onSelect: _switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
return LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 640;
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
compact: narrow,
onSelect: _c.switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: narrow ? 170 : 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
),
),
),
),
),
IconButton(
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
if (widget.onToggleExpanded != null)
IconButton(
key: const ValueKey('jf-expand'),
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
],
);
],
);
});
}
}
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
final JellyfinAccount current;
final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations;
/// Narrow: the name is shorter.
final bool compact;
const _ServerSwitcher({
required this.accounts,
required this.current,
required this.onSelect,
this.onOpenIntegrations,
this.compact = false,
});
@override
@@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget {
StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
),
+118 -120
View File
@@ -2,12 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
import '../shell/motion.dart' show HScroll;
enum CardShape {
/// 2:3 poster (movies, series, latest).
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
_ => Icons.movie_outlined,
};
/// Two clicks on the same card within this time put it up.
const doubleClickTime = Duration(milliseconds: 350);
/// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and
/// focus feed the inspector, "i" or the hover button opens the details.
/// and one meta line. Click / Enter selects it (details panel; libraries
/// and folders open), a second click soon after puts it up. [display]
/// gives the picture and title when they are not [item]'s own (a series
/// card for a new episode), [subtitle] replaces the meta line.
class MediaCard extends StatefulWidget {
final JellyfinItem item;
final JellyfinItem? display;
final String? subtitle;
final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top,
/// "S2E3 · 12 min left" below.
/// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
/// left" below.
final bool seriesFirst;
final bool autofocus;
const MediaCard({
super.key,
required this.item,
required this.shape,
this.display,
this.subtitle,
this.seriesFirst = false,
this.autofocus = false,
});
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
class _MediaCardState extends State<MediaCard> {
bool _hover = false;
bool _focus = false;
DateTime? _lastTap;
JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
_details(scope);
return KeyEventResult.handled;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
_lastTap = null;
scope.onPutUp(item);
return;
}
return KeyEventResult.ignored;
scope.activate(item);
}
@override
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final shown = widget.display ?? item;
final selected = scope.selectedItemId == item.id;
final played = item.userData.played;
final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount;
final unplayed = shown.userData.unplayedItemCount;
final ring = selected
? scheme.primary
: _focus
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
final image = AspectRatio(
aspectRatio: widget.shape.aspect,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
duration: const Duration(milliseconds: 160),
curve: Curves.fastEaseInToSlowEaseOut,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ring, width: 2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ring, width: 3),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(7),
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: cardImage(item, widget.shape),
icon: kindIcon(item),
image: cardImage(shown, widget.shape),
icon: kindIcon(shown),
),
if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)),
@@ -141,7 +154,7 @@ class _MediaCardState extends State<MediaCard> {
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
: unplayed != null && unplayed > 0 && !isPlayable(item)
: unplayed != null && unplayed > 0 && !isPlayable(shown) && widget.subtitle == null
? _Badge(
color: scheme.primaryContainer,
child: Text('$unplayed',
@@ -151,78 +164,55 @@ class _MediaCardState extends State<MediaCard> {
color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(),
),
if (_hover && isPlayable(item))
Positioned(
top: 4,
left: 4,
child: IconButton.filledTonal(
key: ValueKey('card-info-${item.id}'),
tooltip: 'Details (I)',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: () => _details(scope),
icon: const Icon(Icons.info_outline),
),
),
],
),
),
),
);
final title = cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => scope.activate(item),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.subtitle ??
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item));
return Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(10),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
],
),
),
],
),
),
);
@@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate {
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
}
/// A titled horizontal row of cards sized to fill the width; arrows (on
/// hover) page through it. Lazy: only visible cards are built.
/// One card of a [MediaRow]: see [MediaCard].
typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle});
List<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 {
final String title;
final List<JellyfinItem> items;
final List<CardEntry> entries;
final CardShape shape;
final bool seriesFirst;
final Widget? trailing;
const MediaRow({
super.key,
required this.title,
required this.items,
required this.entries,
required this.shape,
this.seriesFirst = false,
this.trailing,
@@ -315,44 +312,38 @@ class _MediaRowState extends State<MediaRow> {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final entries = widget.entries;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(widget.title, style: t.titleMedium),
const SizedBox(width: 8),
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
const Spacer(),
?widget.trailing,
],
),
),
SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols;
final overflow = entries.length > fit.cols;
return SizedBox(
height: h,
child: Stack(
children: [
ListView.separated(
HScroll(
controller: _scroll,
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: widget.items[i],
shape: widget.shape,
seriesFirst: widget.seriesFirst,
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: entries[i].item,
display: entries[i].display,
subtitle: entries[i].subtitle,
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
),
),
),
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
children: [
Text(title, style: t.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
const Spacer(),
Expanded(
child: Row(
children: [
Flexible(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
],
),
),
?trailing,
],
),
@@ -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',
};
/// Cards that select (details panel) on click: playable items and series.
bool isSelectable(JellyfinItem item) => isPlayable(item) || item.kind == JellyfinItemKind.series;
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
+143 -73
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_rows.dart';
/// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser.
@@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
_ => Icons.folder_outlined,
};
/// Start page: library shortcuts, then Continue watching, Next up and the
/// latest additions of each library. Each row loads on its own, so a slow
/// library does not hold up the rest.
/// The video libraries of the scope's server (cached).
Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
scope.load('views', (c) => c.userViews()).then((all) => all.where(isVideoLibrary).toList());
/// Start page: (library shortcuts,) one Continue row (resume points and
/// next up merged per series, see [mergeContinue]), then the newest
/// additions of each library as series cards ([latestCards]). Each row
/// loads on its own, so a slow library does not hold up the rest; rows
/// reload when watched state changes ([BrowseController.dataChanged]).
class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key});
@@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()),
builder: (context, all) {
final views = all.where(isVideoLibrary).toList();
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
_LibraryStrip(views: views),
const SizedBox(height: 20),
_Section(
key: const ValueKey('row-resume'),
title: 'Continue watching',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
),
_Section(
key: const ValueKey('row-nextup'),
title: 'Next up',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
),
for (final v in views)
_Section(
key: ValueKey('row-latest-${v.id}'),
title: 'Latest in ${v.name}',
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
trailing: TextButton(
onPressed: () => scope.onOpen(LibraryRoute(v)),
child: const Text('Show all'),
),
),
],
);
},
load: () => videoLibraries(scope),
builder: (context, views) => ListenableBuilder(
listenable: scope.controller,
builder: (context, _) {
final v = scope.controller.version;
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
if (scope.libraryStrip) ...[
LibraryStrip(views: views),
const SizedBox(height: 20),
] else if (views.isEmpty)
const _NoLibraries(),
_ContinueRow(version: v),
for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
],
);
},
),
);
}
}
class _LibraryStrip extends StatelessWidget {
class _NoLibraries extends StatelessWidget {
const _NoLibraries();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(bottom: 20),
child: Text('No video libraries on this server for your user.', style: TextStyle(color: Colors.grey)),
);
}
/// The libraries as chips; a click opens one.
class LibraryStrip extends StatelessWidget {
final List<JellyfinItem> views;
const _LibraryStrip({required this.views});
const LibraryStrip({super.key, required this.views});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (views.isEmpty) {
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
if (views.isEmpty) return const _NoLibraries();
return Wrap(
spacing: 8,
runSpacing: 8,
@@ -92,46 +92,116 @@ class _LibraryStrip extends StatelessWidget {
avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)),
onPressed: () => scope.controller.openTop(LibraryRoute(v)),
),
],
);
}
}
class _Section extends StatelessWidget {
final String title;
final CardShape shape;
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load;
final Widget? trailing;
const _Section({
super.key,
required this.title,
required this.shape,
required this.load,
this.seriesFirst = false,
this.trailing,
});
class _ContinueRow extends StatelessWidget {
final int version;
const _ContinueRow({required this.version});
static const title = 'Continue';
Future<List<JellyfinItem>> _load(BrowseScope scope) async {
final lists = await Future.wait([
scope.load('resume', (c) => c.resume(limit: 40)),
scope.load('nextup', (c) => c.nextUp(limit: 40)),
// Only for ordering: a failure leaves the row as it is.
scope
.load('played', (c) => c.recentlyPlayed(limit: 150))
.catchError((Object _) => const <JellyfinItem>[]),
]);
return mergeContinue(
resume: lists[0],
nextUp: lists[1],
recentlyPlayed: lists[2],
dismissed: scope.accounts.continueDismissed(scope.account.serverId),
now: scope.controller.now(),
);
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<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,
loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
items: items,
shape: shape,
seriesFirst: seriesFirst,
trailing: trailing,
),
builder: (context, items) => SignalBuilder(builder: (context) {
final hide = AppSettings.instance.hideReReleases.value;
final cards = latestCards(items, hideReReleases: hide);
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
key: ValueKey('latest-${view.id}'),
title: title,
entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)],
shape: shape,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: ValueKey('show-all-${view.id}'),
// Newest first, like the row.
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
child: const Text('Show all'),
),
PopupMenuButton<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.
class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder});
/// A [LibrarySort] name to start with (in its natural direction).
final String? sort;
const JellyfinLibraryPage({super.key, required this.folder, this.sort});
@override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
late LibrarySort _sort =
LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
late bool _descending = _sort.descending;
bool _unplayed = false;
bool _favorites = false;
@@ -1,31 +1,32 @@
import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'browser.dart';
import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server
/// is linked, else an inline add-server panel.
/// The Library's Jellyfin source: the library browser once a server is
/// linked, else an inline add-server panel.
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
final BrowseController? controller;
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseStart? start;
final bool libraryStrip;
const JellyfinPickerSource({
super.key,
this.start,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onOpenIntegrations,
this.onToggleExpanded,
this.expanded = false,
this.libraryStrip = true,
});
@override
@@ -52,12 +53,13 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
accounts: a,
controller: widget.controller,
selected: widget.selected,
onPick: widget.onPick,
onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.start,
libraryStrip: widget.libraryStrip,
);
},
);
+112 -116
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart';
import 'images.dart';
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
version: scope.controller.version,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onPick(next),
onPressed: next == null ? null : () => scope.onSelect(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
class _EpisodeList extends StatefulWidget {
final String seriesId;
final String seasonId;
const _EpisodeList({super.key, required this.seriesId, required this.seasonId});
final int version;
const _EpisodeList(
{super.key, required this.seriesId, required this.seasonId, required this.version});
@override
State<_EpisodeList> createState() => _EpisodeListState();
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
class _EpisodeListState extends State<_EpisodeList> {
Future<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}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
}
final eps = snap.data;
final eps = snap.data ?? _last;
if (snap.data != null) _last = snap.data;
if (eps == null) {
return const SliverToBoxAdapter(
child: Padding(
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click picks it; I opens details.
/// date, two lines of overview. Click selects it (details panel), a second
/// click soon after puts it up.
class EpisodeTile extends StatefulWidget {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false;
bool _hover = false;
DateTime? _lastTap;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime) {
_lastTap = null;
scope.onPutUp(widget.episode);
} else {
scope.onSelect(widget.episode);
}
}
@override
Widget build(BuildContext context) {
@@ -332,122 +358,92 @@ class _EpisodeTileState extends State<EpisodeTile> {
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, k) {
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
scope.onOpen(DetailRoute(e));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => scope.onPick(e),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
return Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
if (_hover)
IconButton(
tooltip: 'Details (I)',
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
iconSize: 18,
onPressed: () => scope.onOpen(DetailRoute(e)),
icon: const Icon(Icons.info_outline),
),
]),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
),
],
),
],
),
),
],
),
),
),
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
@@ -41,7 +42,8 @@ class AboutPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
return SettingsPanel(
title: 'About',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1,34 +1,25 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../../jellyfin/picker_source.dart';
import '../../jellyfin/browse_scope.dart' show JellyfinSelection;
import '../../widgets/media_input.dart';
import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
/// The Library's sources.
enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the
/// Library tab) so it survives rebuilds and tab switches.
/// State of the Library tab, owned by the shell so it survives rebuilds
/// and tab switches: the source shown, the link field and its relay mode,
/// the selected Jellyfin card (details panel) and the last Jellyfin pick.
class MediaPickerController extends ChangeNotifier {
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_jellyfin = jellyfin,
_source = source ??
(linkUrl.isNotEmpty
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
_selected = jellyfin == null ? null : JellyfinSelection(jellyfin.serverId, jellyfin.itemId),
_source = source ?? MediaKind.jellyfin {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
@@ -36,8 +27,9 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link;
final TextEditingController file;
MediaKind _source;
String? _notice;
LinkRelay _linkRelay = LinkRelay.direct;
JellyfinMedia? _jellyfin;
JellyfinSelection? _selected;
LinkRelay get linkRelay => _linkRelay;
set linkRelay(LinkRelay v) {
@@ -45,17 +37,23 @@ class MediaPickerController extends ChangeNotifier {
_linkRelay = v;
notifyListeners();
}
JellyfinMedia? _jellyfin;
/// The Jellyfin item picked in the browser (kept while another source
/// is shown).
/// The last Jellyfin item picked (restored from the last put-up).
JellyfinMedia? get jellyfin => _jellyfin;
/// Picks a Jellyfin item and switches to the Jellyfin source.
/// Picks a Jellyfin item: selects its card and switches to Jellyfin.
void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media;
_selected = media == null ? null : JellyfinSelection(media.serverId, media.itemId);
_source = MediaKind.jellyfin;
_notice = null;
notifyListeners();
}
/// The selected Jellyfin card (details panel open), or null.
JellyfinSelection? get selected => _selected;
void select(JellyfinSelection? s) {
if (s == _selected && s?.item == _selected?.item) return;
_selected = s;
notifyListeners();
}
@@ -63,18 +61,10 @@ class MediaPickerController extends ChangeNotifier {
set source(MediaKind v) {
if (v == _source) return;
_source = v;
_notice = null;
notifyListeners();
}
/// One-line hint under the fields (e.g. the file dialog failed).
String? get notice => _notice;
set notice(String? v) {
_notice = v;
notifyListeners();
}
/// What the visible source has selected; null = nothing.
/// What the Link or File source holds; for Jellyfin the last pick.
PickedMedia? get selection {
switch (_source) {
case MediaKind.link:
@@ -107,292 +97,3 @@ class MediaPickerController extends ChangeNotifier {
super.dispose();
}
}
/// Source segments (Jellyfin / Link / File) and the matching input.
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<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:signals/signals_flutter.dart';
import '../../playback/playback_controller.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'ui.dart';
@@ -26,7 +24,8 @@ const sessionShortcuts = [
('Esc', 'Close the panel or fullscreen, else ask to leave'),
];
/// One titled panel of the Settings tab.
/// One titled card of the Settings tab (rounded Material, so list tiles
/// paint their ripple on it).
class SettingsPanel extends StatelessWidget {
final String title;
final Widget child;
@@ -36,76 +35,56 @@ class SettingsPanel extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [SectionLabel(title), child],
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
child,
],
),
),
),
);
}
}
class HwdecSetting extends StatefulWidget {
const HwdecSetting({super.key});
@override
State<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();
}
/// A tinted hint card at the top of a Settings page.
class SettingsHintCard extends StatelessWidget {
final IconData icon;
final String text;
const SettingsHintCard({super.key, required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final mode = _s.hwdecMode.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: kGap),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Text('Hardware decoding'),
const SizedBox(height: 4),
const Text(
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
'shows glitches or a black picture.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
SegmentedButton<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,
),
),
Icon(icon, color: scheme.primary),
const SizedBox(width: 12),
Expanded(child: Text(text)),
],
);
});
),
);
}
}
class ShaderSettings extends StatelessWidget {
const ShaderSettings({super.key});
/// The .glsl files of the "Custom shaders" preset, in mpv order.
class CustomShaderFiles extends StatelessWidget {
const CustomShaderFiles({super.key});
Future<void> _addFiles() async {
final List<PlatformFile> files;
@@ -130,65 +109,18 @@ class ShaderSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
final muted = TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant);
return SignalBuilder(builder: (context) {
final preset = s.shaderPreset.value;
final quality = s.shaderQuality.value;
final custom = s.customShaders.value;
final mode = PlaybackController.lastRenderMode.value;
const hint = TextStyle(fontSize: 12, color: Colors.grey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Anime4K upscales and sharpens anime on your GPU, for you only. '
'Mode A suits 1080p sources, B 720p, C 480p; A+A, B+B and C+A add '
'a second pass. HQ is for strong GPUs (GTX 1080 and up), Fast for '
'weaker ones. Costs GPU time every frame and only works with '
'hardware video rendering. In the player: the ✨ button or '
'Ctrl+1…6, Ctrl+0 for off; changes apply live and are saved here.',
style: hint,
),
if (mode == RenderMode.software || mode == RenderMode.softwareGl)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
mode == RenderMode.software
? 'The last session used software rendering: shaders had no effect there.'
: 'The last session rendered OpenGL on the CPU: shaders will be slow.',
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
),
const SizedBox(height: 12),
Row(
children: [
const Text('Shaders'),
const SizedBox(width: 16),
DropdownButton<ShaderPreset>(
value: preset,
onChanged: (p) {
if (p != null) s.setShaderPreset(p);
},
items: [
for (final p in ShaderPreset.values)
DropdownMenuItem(value: p, child: Text(p.label)),
],
Expanded(
child: Text('Your own mpv shaders for the "Custom shaders" choice; they run in this order.',
style: muted),
),
const Spacer(),
SegmentedButton<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(
onPressed: _addFiles,
icon: const Icon(Icons.add, size: 18),
@@ -197,7 +129,7 @@ class ShaderSettings extends StatelessWidget {
],
),
if (custom.isEmpty)
const Text('None picked.', style: hint)
Text('None picked.', style: muted)
else
for (final (i, path) in custom.indexed)
Row(
@@ -205,15 +137,13 @@ class ShaderSettings extends StatelessWidget {
Expanded(
child: Tooltip(
message: path,
child: Text(path.split(RegExp(r'[/\\]')).last,
overflow: TextOverflow.ellipsis),
child: Text(path.split(RegExp(r'[/\\]')).last, overflow: TextOverflow.ellipsis),
),
),
IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () =>
s.setCustomShaders([...custom]..removeAt(i)),
onPressed: () => s.setCustomShaders([...custom]..removeAt(i)),
),
],
),
@@ -264,8 +194,10 @@ class UpdateSettings extends StatelessWidget {
const SizedBox(height: 8),
SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('auto-check-updates'),
contentPadding: EdgeInsets.zero,
title: const Text('Check automatically on start'),
title: const Text('Check for updates on start'),
subtitle: const Text('Looks for a new version when the app opens; nothing installs without asking'),
value: s.autoCheckUpdates.value,
onChanged: s.setAutoCheckUpdates,
),
@@ -275,26 +207,6 @@ class UpdateSettings extends StatelessWidget {
}
}
class MpvControlsSetting extends StatelessWidget {
const MpvControlsSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('mpv-controls'),
contentPadding: EdgeInsets.zero,
title: const Text('Experimental: mpv-style controls'),
subtitle: const Text('Video controls modeled on mpv\'s uosc theme: a thick timeline with '
'chapters and the cached range, flat buttons and menus. Applies right away.'),
value: s.mpvControls.value,
onChanged: s.setMpvControls,
),
);
}
}
class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key});
@@ -306,8 +218,8 @@ class AutoAdvanceSetting extends StatelessWidget {
key: const ValueKey('auto-advance'),
contentPadding: EdgeInsets.zero,
title: const Text('Play the next episode automatically'),
subtitle: const Text('When an episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it on their screen.'),
subtitle: const Text('When a Jellyfin episode ends, a 10 s countdown puts up the next one; '
'anyone can cancel it'),
value: s.autoAdvance.value,
onChanged: s.setAutoAdvance,
),
@@ -315,6 +227,26 @@ class AutoAdvanceSetting extends StatelessWidget {
}
}
class HideReReleasesSetting extends StatelessWidget {
const HideReReleasesSetting({super.key});
@override
Widget build(BuildContext context) {
final s = AppSettings.instance;
return SignalBuilder(
builder: (context) => SwitchListTile(
key: const ValueKey('hide-re-releases'),
contentPadding: EdgeInsets.zero,
title: const Text('Hide re-releases in Latest'),
subtitle: const Text('Skips episodes added more than 30 days after they aired '
'(re-uploads, upgrades); also in each Latest row\'s ⋮ menu'),
value: s.hideReReleases.value,
onChanged: s.setHideReReleases,
),
);
}
}
class ShortcutList extends StatelessWidget {
const ShortcutList({super.key});
@@ -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 String text;
final DateTime at;
const ToastItem({required this.id, required this.text, required this.at});
/// The member a feed or chat toast is about (their name, as the feed
/// carries it; [text] starts with it); null for the app's own toasts.
final String? actor;
const ToastItem({required this.id, required this.text, required this.at, this.actor});
}
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
@@ -705,7 +708,7 @@ class SessionCoordinator {
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor)
];
}
final kind = sfxForFeedAction(f.action);
@@ -609,6 +609,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder,
windowControls: widget.windowControls,
),
),
),
+3 -2
View File
@@ -103,8 +103,9 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale,
// Only your own app knows it hosts: the state carries no host flag.
host: self && coord.hostFile != null,
// The state marks the host (`members[].host`); your own app also
// knows before the first state.
host: m.host || (self && coord.hostFile != null),
self: self,
);
}
@@ -0,0 +1,621 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart';
import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link
// is, how the others get it (in a server), and the recent links.
const maxRecentLinks = 12;
const _keyRecentLinks = 'recentLinks';
/// A link put up before, newest first in [RecentLinks].
class RecentLink {
final String url;
final String title;
final String? thumbnail;
final DateTime at;
const RecentLink({required this.url, required this.title, this.thumbnail, required this.at});
Map<String, Object?> toJson() =>
{'url': url, 'title': title, 'thumb': ?thumbnail, 'at': at.millisecondsSinceEpoch};
static RecentLink? fromJson(Object? j) {
if (j is! Map) return null;
final url = j['url'], title = j['title'], at = j['at'], thumb = j['thumb'];
if (url is! String || url.isEmpty) return null;
return RecentLink(
url: url,
title: title is String && title.isNotEmpty ? title : LinkMedia(url).label,
thumbnail: thumb is String && thumb.isNotEmpty ? thumb : null,
at: DateTime.fromMillisecondsSinceEpoch(at is int ? at : 0),
);
}
}
/// [link] first, its older copy dropped, at most [maxRecentLinks].
List<RecentLink> pushRecentLink(List<RecentLink> list, RecentLink link) =>
[link, ...list.where((l) => l.url != link.url)].take(maxRecentLinks).toList();
String encodeRecentLinks(List<RecentLink> list) => jsonEncode([for (final l in list) l.toJson()]);
List<RecentLink> decodeRecentLinks(Object? raw) {
try {
final j = raw is String ? jsonDecode(raw) : raw;
if (j is! List) return const [];
return [for (final e in j) ?RecentLink.fromJson(e)].take(maxRecentLinks).toList();
} catch (_) {
return const [];
}
}
/// The recent links, kept in the `connect_form` box next to the last link.
class RecentLinks {
static final RecentLinks instance = RecentLinks();
RecentLinks({Future<Object?> Function()? read, Future<void> Function(String value)? write})
: _read = read ?? _hiveRead,
_write = write ?? _hiveWrite;
final Future<Object?> Function() _read;
final Future<void> Function(String value) _write;
final links = ValueNotifier<List<RecentLink>>(const []);
Future<void>? _load;
static Future<Object?> _hiveRead() async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).get(_keyRecentLinks);
static Future<void> _hiveWrite(String value) async =>
(connectFormBoxIfOpen() ?? await openConnectFormBox()).put(_keyRecentLinks, value);
Future<void> ensureLoaded() => _load ??= () async {
try {
final read = decodeRecentLinks(await _read());
// Something added meanwhile stays on top.
var all = read;
for (final l in links.value.reversed) {
all = pushRecentLink(all, l);
}
links.value = all;
} catch (e) {
debugPrint('load recent links failed: $e');
}
}();
Future<void> add(RecentLink link) async {
links.value = pushRecentLink(links.value, link);
try {
await _write(encodeRecentLinks(links.value));
} catch (e) {
debugPrint('save recent links failed: $e');
}
}
}
// ------------------------------------------------------------- preview
/// What a pasted link is, for the preview card and the recent list.
class LinkPreview {
final String title;
/// "youtube.com · Channel", or the host.
final String subtitle;
final String? thumbnail;
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
}
/// GET [uri] and return the body (oEmbed).
typedef OEmbedGetter = Future<String> Function(Uri uri);
Future<String> defaultOEmbedGet(Uri uri) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(const Duration(seconds: 8));
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
} finally {
client.close(force: true);
}
}
bool isYouTubeLink(Uri uri) {
final h = uri.host.toLowerCase();
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
}
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
/// channel and thumbnail; anything else (or a failed lookup) shows the
/// file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
if (!isYouTubeLink(uri)) return plain;
try {
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
final j = jsonDecode(body);
if (j is! Map || j['title'] is! String) return plain;
final author = j['author_name'];
return LinkPreview(
title: j['title'] as String,
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
);
} catch (_) {
return plain;
}
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
String agoText(DateTime at, DateTime now) {
final d = now.difference(at).inDays;
if (d < 1) return 'today';
if (d < 2) return 'yesterday';
if (d < 7) return '$d days ago';
if (d < 14) return 'last week';
if (d < 60) return '${d ~/ 7} weeks ago';
if (d < 365) return '${d ~/ 30} months ago';
return 'over a year ago';
}
// -------------------------------------------------------------- widget
const _modes = [
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
(
LinkRelay.server,
Icons.dns_outlined,
'Server fetches it',
'Downloaded once by the server. For links slow or blocked for some.'
),
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
];
String linkRelayHelp(LinkRelay r) => switch (r) {
LinkRelay.direct => 'Everyone streams the link themselves. Jellyfin links are shared as the item, '
'without your sign-in.',
LinkRelay.server => 'The server downloads it once and streams it to everyone. It must be able to '
'reach the link; the link is not shown to anyone.',
LinkRelay.me => 'This computer downloads it and passes it on through the server. Keep the app open '
'while it plays; the link is not shown to anyone.',
};
/// The Link source. [offlineHint] set: the button is off and says why.
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final String? offlineHint;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents;
final OEmbedGetter oembed;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
required this.recents,
this.offlineHint,
this.oembed = defaultOEmbedGet,
});
@override
State<LibraryLinkSource> createState() => _LibraryLinkSourceState();
}
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
MediaPickerController get _c => widget.controller;
final _previews = <String, LinkPreview?>{};
String _previewUrl = '';
Timer? _debounce;
bool _busy = false;
String? _error;
@override
void initState() {
super.initState();
widget.recents.ensureLoaded();
_c.link.addListener(_typed);
_typed();
}
@override
void dispose() {
_debounce?.cancel();
_c.link.removeListener(_typed);
super.dispose();
}
void _typed() {
final url = _c.link.text.trim();
if (url == _previewUrl) return;
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _preview(url));
if (mounted) setState(() => _error = null);
}
Future<void> _preview(String url) async {
_previewUrl = url;
if (url.isEmpty || _previews.containsKey(url)) {
if (mounted) setState(() {});
return;
}
final p = await previewLink(url, get: widget.oembed);
_previews[url] = p;
if (mounted) setState(() {});
}
Future<void> _paste() async {
try {
final text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
if (text != null && text.isNotEmpty) _c.link.text = text;
} catch (_) {}
}
Future<void> _go() async {
final url = _c.link.text.trim();
if (url.isEmpty || _busy || widget.offlineHint != null) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(LinkMedia(url, relay: _c.linkRelay));
} catch (e) {
error = '$e';
}
if (error == null) {
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
unawaited(widget.recents.add(RecentLink(
url: url,
title: p?.title ?? LinkMedia(url).label,
thumbnail: p?.thumbnail,
at: DateTime.now(),
)));
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final hint = widget.offlineHint;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
final field = TextField(
key: const ValueKey('link-field'),
controller: _c.link,
style: const TextStyle(fontSize: 16),
keyboardType: TextInputType.url,
onSubmitted: (_) => _go(),
decoration: InputDecoration(
hintText: 'https://…',
prefixIcon: const Icon(Icons.link),
suffixIcon: IconButton(onPressed: _paste, tooltip: 'Paste', icon: const Icon(Icons.content_paste)),
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
);
final button = FilledButton.icon(
key: const ValueKey('link-put-up'),
onPressed: url.isEmpty || _busy || hint != null ? null : _go,
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(hint == null ? 'Put it up' : 'Play'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
);
return SingleChildScrollView(
key: const ValueKey('media-link'),
padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 780),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(18),
),
child: Icon(Icons.link, size: 32, color: scheme.primary),
),
),
const SizedBox(height: 14),
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6),
Text(
'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 24),
LayoutBuilder(
builder: (context, box) => box.maxWidth < 460
? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
field,
const SizedBox(height: 10),
button,
])
: Row(children: [Expanded(child: field), const SizedBox(width: 12), button]),
),
if (hint != null || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? hint!,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
),
],
AnimatedSize(
duration: easeTime,
curve: ease,
child: url.isEmpty
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: _PreviewCard(preview: preview, loading: loading),
),
),
if (hint == null) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) {
final cards = [
for (final m in _modes)
_ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
title: m.$3,
sub: m.$4,
selected: _c.linkRelay == m.$1,
onTap: () => setState(() => _c.linkRelay = m.$1),
),
];
if (box.maxWidth < 560) {
return Column(children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
]);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: c),
],
],
),
);
}),
const SizedBox(height: 8),
Text(linkRelayHelp(_c.linkRelay), style: t.bodySmall?.copyWith(color: Colors.grey)),
],
ValueListenableBuilder<List<RecentLink>>(
valueListenable: widget.recents.links,
builder: (context, recents, _) => recents.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 32),
child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url),
),
),
],
),
),
),
);
}
}
class _PreviewCard extends StatelessWidget {
final LinkPreview? preview;
final bool loading;
const _PreviewCard({required this.preview, required this.loading});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final p = preview;
return Container(
key: const ValueKey('link-preview'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: scheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16)),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(width: 160, height: 90, child: _Thumb(url: p?.thumbnail)),
),
const SizedBox(width: 16),
Expanded(
child: loading
? const Align(
alignment: Alignment.centerLeft,
child: SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p?.title ?? 'Not a link',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
const SizedBox(height: 4),
Text(
p?.subtitle ?? 'Paste an http(s) address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
],
),
),
],
),
);
}
}
class _Thumb extends StatelessWidget {
final String? url;
const _Thumb({required this.url});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final fallback = ColoredBox(
color: scheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.movie_outlined, color: Colors.grey)),
);
final u = url;
if (u == null) return fallback;
return Image.network(u, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
}
}
class _ModeCard extends StatelessWidget {
final IconData icon;
final String title, sub;
final bool selected;
final VoidCallback onTap;
const _ModeCard({
super.key,
required this.icon,
required this.title,
required this.sub,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
const SizedBox(height: 2),
Text(sub,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
),
);
}
}
class _RecentGrid extends StatelessWidget {
final List<RecentLink> recents;
final ValueChanged<RecentLink> onPick;
const _RecentGrid({required this.recents, required this.onPick});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Recent', style: t.titleSmall),
const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) {
const gap = 12.0;
final cols = box.maxWidth > 600 ? 3 : 2;
final w = (box.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final l in recents)
SizedBox(
width: w,
child: Tooltip(
message: l.url,
waitDuration: const Duration(milliseconds: 600),
child: InkWell(
key: ValueKey('recent-link-${l.url}'),
onTap: () => onPick(l),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _Thumb(url: l.thumbnail),
),
),
const SizedBox(height: 6),
Text(l.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(
[_host(Uri.tryParse(l.url) ?? Uri()), agoText(l.at, now)]
.where((s) => s.isNotEmpty)
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
),
),
],
);
}),
],
);
}
}
+359 -16
View File
@@ -1,11 +1,22 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
import '../files/ui/files_page.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/browse_scope.dart';
import '../jellyfin/details_panel.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../screens/session/swap_panel.dart';
import '../state/session_state.dart';
import 'library_link.dart';
import 'motion.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
@@ -39,18 +50,46 @@ Future<void> restoreLastPick(
if (hadMedia) c.source = source;
}
/// Library (round 1): the media picker as the "put it up" view. Offline
/// it browses, with a hint that watching needs a server.
class LibraryTab extends StatelessWidget {
/// The file playing now, when it is the one put up from here last: the
/// server's current media (state JSON) is a plain file (no link, no
/// Jellyfin ref) named like [lastPath].
String? nowPlayingFile(Map<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 JellyfinAccounts accounts;
final bool online;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
/// Puts the pick up; returns an error text, or null when sent.
final Future<String?> Function(PickedMedia media) onPutUp;
final VoidCallback? onPutUpDone;
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final OEmbedGetter oembed;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool online, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
const LibraryTab({
super.key,
required this.controller,
@@ -61,19 +100,323 @@ class LibraryTab extends StatelessWidget {
this.onPutUpDone,
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.oembed = defaultOEmbedGet,
this.filesBuilder,
this.now = DateTime.now,
});
@override
State<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
Widget build(BuildContext context) {
return SwapPanel(
controller: controller,
accounts: accounts,
isHost: isHost,
jellyfinStart: jellyfinStart,
onSwap: onPutUp,
onDone: onPutUpDone,
onOpenIntegrations: onOpenIntegrations,
disabledHint: online ? null : 'Join or host to watch together',
final source = _c.source;
final header = Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: LayoutBuilder(builder: (context, box) {
// Narrow: icons only, so the chips keep some room.
final labels = box.maxWidth >= 620;
ButtonSegment<MediaKind> seg(MediaKind k, IconData icon, String label) => ButtonSegment(
value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label);
return Row(
children: [
SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: [
seg(MediaKind.jellyfin, Icons.video_library_outlined, 'Jellyfin'),
seg(MediaKind.link, Icons.link, 'Link'),
seg(MediaKind.file, Icons.folder_outlined, 'Files'),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
const SizedBox(width: 16),
Expanded(
child: AnimatedSwitcher(
duration: easeTime,
child: source == MediaKind.jellyfin
? _LibraryChips(key: const ValueKey('chips'), controller: _browse)
: const SizedBox(height: 40),
),
),
],
);
}),
);
final Widget content = switch (source) {
MediaKind.jellyfin => Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: JellyfinPickerSource(
accounts: widget.accounts,
controller: _browse,
selected: _c.selected,
onSelect: _c.select,
onPutUp: _doublePutUp,
onOpenIntegrations: widget.onOpenIntegrations,
start: widget.jellyfinStart,
libraryStrip: false,
),
),
MediaKind.link => LibraryLinkSource(
controller: _c,
offlineHint: _hint,
recents: widget.recentLinks ?? RecentLinks.instance,
oembed: widget.oembed,
onPutUp: _put,
),
MediaKind.file => _files(context),
};
final main = Column(
children: [
header,
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
],
);
final open = source == MediaKind.jellyfin && _c.selected != null;
final shown = _shown;
final details = shown == null
? const SizedBox.shrink()
: fadeThrough(KeyedSubtree(
key: ValueKey('${shown.serverId}/${shown.itemId}'),
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
offlineHint: _hint,
onPutUp: _putUpJellyfin,
onClose: () => _c.select(null),
),
));
Widget slide(Widget child) => AnimatedSlide(
duration: easeTime,
curve: ease,
offset: open ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
);
return Focus(
key: const ValueKey('library-tab'),
autofocus: true,
onKeyEvent: _onKey,
child: LayoutBuilder(builder: (context, box) {
if (box.maxWidth >= detailsDockFrom) {
return Row(
children: [
Expanded(child: main),
ClipRect(
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: open ? detailsPanelWidth : 0,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: detailsPanelWidth,
maxWidth: detailsPanelWidth,
child: slide(details),
),
),
),
],
);
}
return Stack(
children: [
Positioned.fill(child: main),
Positioned(
top: 0,
bottom: 0,
right: 0,
width: detailsPanelWidth,
child: IgnorePointer(
ignoring: !open,
child: slide(Material(elevation: 16, child: details)),
),
),
],
);
}),
);
}
Widget _files(BuildContext context) {
final relayed = !widget.isHost;
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = widget.online
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
});
}
}
/// The current server's video libraries as chips in the Library header; a
/// click opens one (the chip of the library open is selected).
class _LibraryChips extends StatelessWidget {
final BrowseController controller;
const _LibraryChips({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([controller, controller.accounts]),
builder: (context, _) {
final accounts = controller.accounts;
final account = accounts.loaded ? controller.account : null;
final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return const SizedBox(height: 40);
final stack = controller.stack;
final openId = stack.length > 1 && stack[1] is LibraryRoute ? (stack[1] as LibraryRoute).folder.id : null;
return FutureBuilder<List<JellyfinItem>>(
future: accounts.cached(account.serverId, 'views', client.userViews),
builder: (context, snap) {
final views = (snap.data ?? const <JellyfinItem>[]).where(isVideoLibrary).toList();
return SizedBox(
height: 40,
child: HScroll(
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal,
itemCount: views.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final v = views[i];
return Center(
child: ChoiceChip(
key: ValueKey('library-${v.id}'),
avatar: Icon(libraryIcon(v), size: 16),
showCheckmark: false,
label: Text(v.name),
selected: v.id == openId,
onSelected: (_) => v.id == openId
? controller.popTo(0)
: controller.openTop(LibraryRoute(v)),
),
);
},
),
),
);
},
);
},
);
}
}
+3 -1
View File
@@ -35,12 +35,14 @@ Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
);
}
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65.
// Same widget shape as the incoming branch, or the old view would
// remount (and lose its state) while it fades.
return FadeTransition(
opacity: CurvedAnimation(
parent: anim,
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
),
child: c,
child: ScaleTransition(scale: const AlwaysStoppedAnimation(1.0), child: c),
);
},
child: child,
+122 -8
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -339,15 +341,49 @@ class StripTab extends StatelessWidget {
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// and per member force ready and kick.
/// per member force ready and kick, and the address to invite friends.
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
/// loopback nor link-local, private (LAN) ranges first, no repeats.
List<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 {
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
Widget build(BuildContext context) {
@@ -392,22 +428,26 @@ class MembersCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
if (address != null) ...[
const Divider(),
const Divider(),
if (address != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () =>
Clipboard.setData(ClipboardData(text: address)),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy address'),
label: const Text('Copy invite address'),
),
),
)
else
_HostInvite(
port: session.lastForm.hostPort,
lanAddresses: lanAddresses,
),
] else
const SizedBox(height: 8),
],
);
},
@@ -471,7 +511,7 @@ class _MemberTile extends StatelessWidget {
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self)
if (!info.self && !info.host)
PopupMenuItem(
value: 'kick',
child: Text(
@@ -484,3 +524,77 @@ class _MemberTile extends StatelessWidget {
);
}
}
/// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget {
final String port;
final Future<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),
),
),
],
),
);
},
);
}
}
+70 -17
View File
@@ -5,6 +5,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart';
import '../controls/player_settings.dart';
import '../controls/uosc/buttons.dart';
import '../controls/uosc/controls.dart';
import '../controls/uosc/timeline.dart';
@@ -19,6 +21,7 @@ import '../state/session_state.dart';
import '../state/settings.dart';
import '../widgets/shown_frame_probe.dart';
import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -52,6 +55,9 @@ class SessionPlayer extends StatefulWidget {
/// Asks before leaving the server.
final VoidCallback onLeave;
final VideoViewBuilder? videoBuilder;
/// Window buttons in the fullscreen top bar (off in tests: they need
/// window_manager).
final bool windowControls;
const SessionPlayer({
super.key,
required this.mode,
@@ -67,6 +73,7 @@ class SessionPlayer extends StatefulWidget {
this.chatOpen = false,
this.unread = 0,
this.videoBuilder,
this.windowControls = true,
});
@override
@@ -87,6 +94,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
int _toastSeq = 0;
int _shaderNoticeSeen = 0;
bool _miniHover = false;
// Whether the standard controls are up (tracked like media_kit does:
// shown on mount and on mouse movement, hidden 3 s later or on exit).
final _standardShown = ValueNotifier(true);
SessionCoordinator get _coord => SessionState.instance.coordinator;
bool get _full => widget.mode == PlayerMode.full;
@@ -114,6 +124,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
_stopShaderWatch();
_noFocus.dispose();
_standardShown.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)
/// and applied live. A null quality keeps the saved one.
void _pickShader(ShaderPreset preset, ShaderQuality? quality) {
final s = AppSettings.instance;
if (quality != null) s.setShaderQuality(quality);
s.setShaderPreset(preset);
_coord.markInput();
_coord.playback.applyShaders(announce: true);
}
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
void _toggleChat() {
_coord.markInput();
@@ -274,6 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_full) ...[
_healthOverlay(),
_waitingPill(bottom: 124),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(),
] else
@@ -300,6 +306,45 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
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 {
final ref = _coord.currentRef.value;
@@ -320,7 +365,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(seconds: 3),
controlsHoverDuration: _controlsHide,
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
// which grows by its height; the top bar keeps its row at the top.
displaySeekBar: false,
@@ -359,6 +404,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
),
@@ -455,6 +501,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onPick: _pickShader,
onMenuOpen: menuOpen,
),
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
MaterialDesktopCustomButton(
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
onPressed: widget.onToggleFullscreen,
@@ -463,20 +510,24 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
Widget _sessionControls(VideoState state) {
// Settings → "Experimental: mpv-style controls" swaps them live.
// The "Player design" setting swaps them live.
return SessionControlsSwitch(
material: (_) => DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _barHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
material: (_) => ControlsHoverProbe(
shown: _standardShown,
hideAfter: _controlsHide,
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _barHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
),
),
mpv: (_) => _mpvControls(),
);
}
/// The experimental uosc-style controls: same actions as the bars above,
/// The uosc controls: same actions as the bars above,
/// all through the coordinator (seeks via `player.seek`, the user path).
Widget _mpvControls() {
final coord = _coord;
@@ -504,6 +555,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
),
?_fullscreenWindowButtons(),
],
),
timeline: (context, expanded) => SignalBuilder(
@@ -593,6 +645,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
onChanged: (x) => playback.player.setVolume(x),
),
),
UoscPlayerSettingsButton(renderMode: playback.renderMode),
UoscButton(
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
tooltip: 'Fullscreen (F)',
@@ -930,7 +983,7 @@ class WaitingPill extends StatelessWidget {
class _MiniSeekBar extends StatelessWidget {
final bool thin;
final ValueChanged<Duration>? onSeek;
const _MiniSeekBar({this.thin = false, this.onSeek});
const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
@override
Widget build(BuildContext context) {
+281 -10
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -12,8 +15,10 @@ import 'motion.dart';
/// The Settings tab's pages, in nav order.
enum SettingsSection {
profile(Icons.person_outline, 'Profile'),
network(Icons.hub_outlined, 'Party & network'),
player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
notifications(Icons.notifications_none, 'Notifications & sounds'),
updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About');
@@ -23,9 +28,9 @@ enum SettingsSection {
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow). The
/// pages hold the existing settings panels; the shell owns [section] so
/// the profile card can open a page.
/// App-wide settings under a left nav (chips on top when narrow), each
/// page a column of cards; the shell owns [section] so the profile card
/// can open a page.
class SettingsTab extends StatelessWidget {
final SettingsSection section;
final ValueChanged<SettingsSection> onSection;
@@ -33,6 +38,8 @@ class SettingsTab extends StatelessWidget {
final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// AnimeFlow's `/me` for the integrations page (tests pass a fake).
final Future<AnimeFlowMe> Function()? animeFlowMe;
const SettingsTab({
super.key,
required this.section,
@@ -40,6 +47,7 @@ class SettingsTab extends StatelessWidget {
this.onEditProfile,
this.updates,
this.jellyfin,
this.animeFlowMe,
});
List<Widget> _page(BuildContext context) {
@@ -64,25 +72,43 @@ class SettingsTab extends StatelessWidget {
),
),
];
case SettingsSection.network:
return const [HostDefaultsPanel(), RecentServersPanel()];
case SettingsSection.player:
return const [
return [
const SettingsHintCard(
icon: Icons.tune,
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
),
SettingsPanel(
title: 'Player',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
child: JellyfinAccountsPanel(accounts: accounts),
),
AnimeFlowCard(
me: animeFlowMe ??
() async {
final client = AnimeFlowClient.fromAccounts(accounts);
try {
return await client.me();
} finally {
client.close();
}
},
),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
const SettingsPanel(title: 'Jellyfin library', child: HideReReleasesSetting()),
];
case SettingsSection.notifications:
return const [NotificationsInfo()];
case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts:
@@ -111,6 +137,8 @@ class SettingsTab extends StatelessWidget {
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
key: ValueKey('settings-${s.name}'),
avatar: Icon(s.icon, size: 18),
showCheckmark: false,
label: Text(s.label),
selected: section == s,
onSelected: (_) => onSection(s),
@@ -168,3 +196,246 @@ class SettingsTab extends StatelessWidget {
);
}
}
/// Party & network: what the Host card starts with.
class HostDefaultsPanel extends StatefulWidget {
const HostDefaultsPanel({super.key});
@override
State<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'),
),
],
);
}
}
+20 -29
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show MemberView;
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
@@ -21,29 +20,26 @@ class ShellToast {
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
}
/// Splits a coordinator toast into who and what: feed toasts start with
/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's.
ShellToast shellToastFor(ToastItem t, Iterable<String> names, AvatarInfo Function(String name) avatar) {
final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length);
for (final n in sorted) {
if (t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
if (t.text.startsWith('$n ')) {
final rest = t.text.substring(n.length + 1);
final icon = rest.startsWith('joined')
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('kicked')
? Icons.gavel
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
}
/// Splits a coordinator toast into who and what: feed and chat toasts
/// name their [ToastItem.actor] and start with it ("Bob joined", "Bob:
/// hi"); the rest are the app's.
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
final n = t.actor;
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
if (t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
final icon = rest.startsWith('joined')
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('kicked')
? Icons.gavel
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each
@@ -75,13 +71,8 @@ class _PartyToastsState extends State<PartyToasts> {
if (fresh.isEmpty) return;
// Only new toasts re-run this, not the party they mention.
untracked(() {
final r = coord.serverState.value;
final names = [
for (final m in r?.members ?? const <MemberView>[]) m.name,
for (final f in coord.feedLog.value) f.actor,
];
for (final t in fresh) {
_add(shellToastFor(t, names, (n) => avatarForName(n, coord)));
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
}
});
});
+24 -2
View File
@@ -18,6 +18,8 @@ const _keyShaderQuality = 'shaderQuality';
const _keyCustomShaders = 'customShaders';
const _keyAutoAdvance = 'autoAdvance';
const _keyMpvControls = 'mpvControls';
const _keyHideReReleases = 'hideReReleases';
const _keyProgressLine = 'progressLine';
enum HwdecMode { auto, off, custom }
@@ -29,9 +31,15 @@ class AppSettings {
final autoCheckUpdates = signal(true);
/// Jellyfin episodes: at the end, count down 10 s and put up the next.
final autoAdvance = signal(true);
/// Experimental: the session uses the mpv-style (uosc) controls
/// (`controls/uosc/`) instead of media_kit's. Applies live.
/// Player design: the uosc controls (`controls/uosc/`) instead of the
/// standard ones (media_kit's with our bars). Applies live.
final mpvControls = signal(false);
/// Library: leave out of "Latest in X" what was added long after it
/// first aired (re-uploads, old seasons added late).
final hideReReleases = signal(true);
/// A 2 px progress line at the bottom of the video while the controls
/// are hidden (both designs). Applies live.
final progressLine = signal(true);
/// Video shaders: one choice, used on join and changed both here and
/// in the session (Ctrl+0..6, the shader button).
@@ -70,6 +78,10 @@ class AppSettings {
if (advance is bool) autoAdvance.value = advance;
final mpv = box.get(_keyMpvControls);
if (mpv is bool) mpvControls.value = mpv;
final hideRe = box.get(_keyHideReReleases);
if (hideRe is bool) hideReReleases.value = hideRe;
final line = box.get(_keyProgressLine);
if (line is bool) progressLine.value = line;
final preset = box.get(_keyShaderPreset);
shaderPreset.value =
ShaderPreset.values.where((p) => p.name == preset).firstOrNull ??
@@ -112,6 +124,16 @@ class AppSettings {
_put(_keyMpvControls, on);
}
void setProgressLine(bool on) {
progressLine.value = on;
_put(_keyProgressLine, on);
}
void setHideReReleases(bool on) {
hideReReleases.value = on;
_put(_keyHideReReleases, on);
}
void setShaderPreset(ShaderPreset preset) {
shaderPreset.value = preset;
_put(_keyShaderPreset, preset.name);
@@ -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();
}
},
);
}
+20 -16
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/ui/files_page.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart';
@@ -82,7 +83,7 @@ void main() {
test('pasting sorts links and paths into the right source', () {
final c = MediaPickerController();
expect(c.source, MediaKind.file);
expect(c.source, MediaKind.jellyfin);
c.pasteText(' https://example.com/a.mkv ');
expect(c.source, MediaKind.link);
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
@@ -129,13 +130,13 @@ void main() {
testWidgets('tabs and settings pages switch the body', (tester) async {
await pumpShell(tester, const Size(1280, 900));
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
expect(find.byKey(const ValueKey('library-tab')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-schedule')));
// The schedule shows a spinner while it loads: pump frames, not settle.
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(find.byKey(const ValueKey('swap-panel')), findsNothing);
expect(find.byKey(const ValueKey('library-tab')), findsNothing);
expect(find.byType(SchedulePage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-settings')));
for (var i = 0; i < 30; i++) {
@@ -148,7 +149,7 @@ void main() {
expect(find.text('Hardware decoding'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-updates')));
await tester.pumpAndSettle();
expect(find.text('Check automatically on start'), findsOneWidget);
expect(find.text('Check for updates on start'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
await tester.pumpAndSettle();
expect(find.text('Toggle ready'), findsOneWidget);
@@ -160,7 +161,7 @@ void main() {
expect(find.text('Dev build — update checks are off'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-library')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
expect(find.byKey(const ValueKey('library-tab')), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -229,21 +230,20 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
});
testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async {
testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.text('File'));
await tester.tap(find.text('Files'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
expect(find.text('Browse'), findsOneWidget);
expect(find.byType(FilesPage), findsOneWidget);
await tester.tap(find.text('Link'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Watch from a link'), findsOneWidget);
// Offline nothing goes up.
expect(find.text('Join or host to watch together'), findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
// No linked server: sign-in right in the picker.
// No linked server: sign-in right in the library.
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.text('Open Integrations'));
await tester.pumpAndSettle();
@@ -269,14 +269,18 @@ void main() {
testWidgets('narrow window: strip, cards and library fit', (tester) async {
await pumpShell(tester, const Size(480, 800));
expect(tester.takeException(), isNull);
final ex = tester.takeException();
expect(ex, isNull);
await openCard(tester, 'strip-join');
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle();
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Narrow: the sources are icons with tooltips.
for (final source in ['Link', 'Files', 'Jellyfin']) {
await tester.tap(find.byTooltip(source));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: source);
}
});
}
+19 -2
View File
@@ -30,6 +30,20 @@ class FakeJellyfin implements JellyfinHttp {
bool offline = false;
int meStatus = 200;
/// Finished episodes (`/Items?filters=IsPlayed&sortBy=DatePlayed`): JoJo
/// and Black Summoner watched on 2026-10-07, after Black Summoner's
/// resume point (10-06), so its next-up episode is what continues.
List<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() {
fixture('GET', '/System/Info/Public', 'pub');
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
@@ -58,6 +72,7 @@ class FakeJellyfin implements JellyfinHttp {
fixture('GET', '/Items/Latest', 'latest_shows');
on('GET', '/Items', (uri, _) {
final q = uri.queryParameters;
if (q['filters'] == 'IsPlayed' && q['sortBy'] == 'DatePlayed') return _page(recentlyPlayed);
if ((q['searchTerm'] ?? '').isNotEmpty) {
return q['searchTerm']!.toLowerCase().startsWith('ari')
? JellyfinHttpResponse(200, jellyfinFixture('search'))
@@ -307,10 +322,12 @@ void main() {
c.dispose();
});
test('a saved Jellyfin pick starts on the Jellyfin source', () {
test('the library starts on Jellyfin unless told otherwise; a saved pick is selected', () {
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin);
expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file);
expect(MediaPickerController(jellyfin: m).selected!.itemId, 'i');
expect(MediaPickerController(mediaPath: '/x.mkv', linkUrl: 'https://a/b').source, MediaKind.jellyfin);
expect(MediaPickerController(source: MediaKind.link).source, MediaKind.link);
});
test('JellyfinMedia round-trips and labels', () {
+161 -111
View File
@@ -1,12 +1,16 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_tab.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/picker_source.dart';
import 'package:watch_together/src/state/settings.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
@@ -30,49 +34,149 @@ Future<void> settle(WidgetTester tester, [int frames = 6]) async {
}
}
/// MediaPicker on the Jellyfin source with one linked fake server.
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker(
/// Pinned "now" for the Continue row (the fixtures were captured in
/// October 2026).
final fixtureNow = DateTime.utc(2026, 10, 8, 12);
/// What the browser reported: selections and put-ups.
class Picks {
final selected = <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 {
await setSize(tester, size);
// Memory store + fake transport: no real IO, so no runAsync (futures
// made there would resume outside the test's fake clock).
final (accounts, fake) = await linkedAccounts();
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
final controller = BrowseController(accounts, now: () => fixtureNow);
addTearDown(controller.dispose);
final picks = Picks();
await tester.pumpWidget(app(StatefulBuilder(
builder: (context, setState) => JellyfinBrowser(
accounts: accounts,
controller: controller,
selected: picks.selected.lastOrNull,
onSelect: (s) => setState(() => picks.selected.add(s)),
onPutUp: picks.putUp.add,
),
)));
await settle(tester);
return (c, accounts, fake);
return (picks, accounts, fake, controller);
}
Future<void> openArifureta(WidgetTester tester) async {
await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta');
await settle(tester, 10);
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
// Series cards select; the series page opens from the details panel or
// a season card, here straight through the controller.
await tester.tap(find.byKey(const ValueKey('card-$arifuretaId')));
await settle(tester);
}
void main() {
testWidgets('home rows: continue watching with time left, next up, latest', (tester) async {
await pumpPicker(tester, const Size(1600, 2400));
expect(find.text('Continue watching'), findsOneWidget);
expect(find.text('Next up'), findsOneWidget);
testWidgets('home: one Continue row (resume + next up per series), latest as series cards',
(tester) async {
final (_, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 2400));
expect(find.text('Continue'), findsOneWidget);
expect(find.text('Continue watching'), findsNothing);
expect(find.text('Next up'), findsNothing);
expect(find.text('Latest in Shows'), findsOneWidget);
expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget);
// Continue watching: series name on top, "S1E10 · N min left · title" below.
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets);
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
expect(find.text('Monogatari'), findsWidgets);
final row = find.byKey(const ValueKey('row-continue'));
// JoJo and Black Summoner were watched yesterday; Black Summoner's
// resume point is older than its last finished episode, so its next
// up shows. Bumpkin (62 days) and the never-dated series drop out.
expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget);
expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget);
expect(find.descendant(of: row, matching: find.textContaining('S1E6')), findsOneWidget);
expect(find.descendant(of: row, matching: find.text('From Old Country Bumpkin to Master Swordsman')),
findsNothing);
final jojo = tester.getTopLeft(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8')));
final bs = tester.getTopLeft(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')));
expect(jojo.dx, lessThan(bs.dx));
expect(controller.continueKeys, {'5055eea092a576ea09eee3b6527ba857', 'e9aec9c14a1fcab775111844112b4cf7'});
// Latest asks for single items and groups them itself; grouped series
// from the server say how many are new.
final latest = fake.requests.lastWhere((r) => r.uri.path == '/Items/Latest').uri.queryParameters;
expect(latest['groupItems'], 'false');
expect(find.text('30 new'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('series page: smart next button, season switching, pick an episode',
testWidgets('cards: click selects, double-click puts up; Show all opens newest first',
(tester) async {
final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000));
final (picks, _, fake, controller) = await pumpBrowser(tester, const Size(1600, 1200));
final card = find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052'));
await tester.tap(card);
await tester.pump();
expect(picks.selected.single.itemId, 'b18826be994070a79f9b52bc0f66f052');
expect(picks.putUp, isEmpty);
// Selected: outline and check.
expect(find.descendant(of: card, matching: find.byIcon(Icons.check)), findsOneWidget);
await tester.tap(card);
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(card);
await tester.pump();
expect(picks.putUp.single.itemId, 'b18826be994070a79f9b52bc0f66f052');
await tester.tap(find.byKey(const ValueKey('show-all-a656b907eb3a73532e40e44b968d0225')));
await settle(tester);
final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
expect(q['sortBy'], startsWith('DateCreated'));
expect(q['sortOrder'], 'Descending');
expect(find.widgetWithText(OutlinedButton, 'Date added'), findsOneWidget);
expect((controller.stack.last as LibraryRoute).sort, 'added');
expect(tester.takeException(), isNull);
});
testWidgets('Latest: "Hide re-releases" hides items added long after airing', (tester) async {
final fake = FakeJellyfin();
fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([
{
'Id': 'fresh', 'Name': 'Fresh', 'Type': 'Episode', 'SeriesId': 'sf', 'SeriesName': 'Fresh Show',
'ParentIndexNumber': 1, 'IndexNumber': 1,
'PremiereDate': '2026-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z',
},
{
'Id': 'old', 'Name': 'Old', 'Type': 'Episode', 'SeriesId': 'so', 'SeriesName': 'Old Show',
'ParentIndexNumber': 1, 'IndexNumber': 4,
'PremiereDate': '2012-10-01T00:00:00Z', 'DateCreated': '2026-10-02T00:00:00Z',
},
])));
await setSize(tester, const Size(1600, 1400));
final (accounts, _) = await linkedAccounts(http: fake);
final controller = BrowseController(accounts, now: () => fixtureNow);
addTearDown(controller.dispose);
AppSettings.instance.hideReReleases.value = true;
addTearDown(() => AppSettings.instance.hideReReleases.value = true);
await tester.pumpWidget(app(JellyfinBrowser(
accounts: accounts, controller: controller, onSelect: (_) {}, onPutUp: (_) {})));
await settle(tester);
expect(find.text('Fresh Show'), findsWidgets);
expect(find.text('S1E1'), findsWidgets);
expect(find.text('Old Show'), findsNothing);
await tester.tap(find.byKey(const ValueKey('latest-menu-a656b907eb3a73532e40e44b968d0225')));
await settle(tester, 4);
await tester.tap(find.byKey(const ValueKey('hide-rereleases')));
await settle(tester, 4);
expect(AppSettings.instance.hideReReleases.value, isFalse);
expect(find.text('Old Show'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('series page: smart next button and episodes select, double-click puts up',
(tester) async {
final (picks, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000));
await openArifureta(tester);
expect(picks.selected.last.itemId, arifuretaId);
controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series')));
await settle(tester);
// Opens on the season holding next up (S2E4).
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
expect(find.text('1. Irregular'), findsOneWidget);
@@ -83,37 +187,38 @@ void main() {
expect(find.text('1. Irregular'), findsNothing);
expect(find.text('1. Haulias Assemble'), findsWidgets);
// Picking an episode fills the controller.
await tester.tap(find.text('2. Roar of Revolution').first);
await tester.pump();
final sel = c.selection;
expect(sel, isA<JellyfinMedia>());
sel as JellyfinMedia;
final sel = picks.selected.last;
expect(sel.serverId, fixtureServerId);
expect(sel.title, 'Roar of Revolution');
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2");
expect(sel.baseUrl, 'https://example.invalid');
expect(sel.item!.name, 'Roar of Revolution');
expect(picks.putUp, isEmpty);
await tester.tap(find.text('2. Roar of Revolution').first);
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.text('2. Roar of Revolution').first);
await tester.pump();
expect(picks.putUp.single.item!.name, 'Roar of Revolution');
// The smart button picks next up.
// The smart button selects next up.
await tester.tap(find.byKey(const ValueKey('series-next')));
await tester.pump();
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4'));
expect(picks.selected.last.item!.episodeLabel, 'S2E4');
expect(tester.takeException(), isNull);
});
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async {
await pumpPicker(tester, const Size(1600, 1000));
final (_, _, _, controller) = await pumpBrowser(tester, const Size(1600, 1000));
final search = find.byKey(const ValueKey('jf-search'));
// Focus a card, then arrow right moves to the next card.
final first = find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3'));
final first = find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8'));
Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first))
.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!;
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7');
expect((ink.key as ValueKey<String>).value, 'card-b18826be994070a79f9b52bc0f66f052');
// "/" jumps to search.
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
@@ -125,21 +230,22 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byKey(const ValueKey('search-results')), findsNothing);
expect(find.text('Continue watching'), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
// Into the series and back with Esc.
await openArifureta(tester);
// Into a series and back with Esc.
controller.open(SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series')));
await settle(tester);
expect(find.byKey(const ValueKey('series-next')), findsOneWidget);
expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byKey(const ValueKey('series-next')), findsNothing);
expect(find.text('Continue watching'), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('library grid with sort and filters', (tester) async {
final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000));
final (_, _, fake, _) = await pumpBrowser(tester, const Size(1600, 1000));
await tester.tap(find.widgetWithText(ActionChip, 'Shows'));
await settle(tester);
expect(find.text('218 items'), findsOneWidget);
@@ -160,33 +266,29 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns',
testWidgets('layouts: narrow and ultrawide fill rows with whole cards; rows scroll by drag',
(tester) async {
await pumpPicker(tester, const Size(820, 700));
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
await pumpBrowser(tester, const Size(820, 700));
expect(tester.takeException(), isNull);
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')));
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-28465756997333b4a6aa4b0039013da8')));
await pumpPicker(tester, const Size(3440, 1440));
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
await pumpBrowser(tester, const Size(3440, 1440));
expect(tester.takeException(), isNull);
// Rows fill the width with whole cards.
final wideCards = find.byWidgetPredicate((w) =>
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
expect(wideCards.evaluate().length, greaterThan(10));
expect(narrowCard.width, greaterThan(200));
// Hover feeds the inspector.
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: Offset.zero);
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first));
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('jf-inspector')),
matching: find.textContaining('Rice and Thieves and Heroes')),
findsOneWidget);
// A Latest row is an HScroll: a mouse drag moves it sideways.
await setSize(tester, const Size(640, 1400));
await settle(tester);
final row = find.byKey(const ValueKey('latest-ee75511ae395034b1e7e657707b15125'));
final list = find.descendant(of: row, matching: find.byType(Scrollable)).first;
final before = tester.state<ScrollableState>(list).position.pixels;
await tester.drag(list, const Offset(-300, 0), kind: PointerDeviceKind.mouse);
await settle(tester, 4);
expect(tester.state<ScrollableState>(list).position.pixels, greaterThan(before));
expect(tester.takeException(), isNull);
});
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
@@ -245,14 +347,12 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('no linked server: the picker offers sign-in inline', (tester) async {
testWidgets('no linked server: the Jellyfin source offers sign-in inline', (tester) async {
await setSize(tester, const Size(1200, 900));
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
await accounts.ensureLoaded(validate: false);
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(SingleChildScrollView(
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
await tester.pumpWidget(app(JellyfinPickerSource(
accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}, onOpenIntegrations: () {})));
await tester.pump();
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
expect(find.text('Open Integrations'), findsOneWidget);
@@ -260,61 +360,11 @@ void main() {
});
testWidgets('expired account shows a reconnect banner in the browser', (tester) async {
final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900));
final (_, accounts, _, _) = await pumpBrowser(tester, const Size(1400, 900));
accounts.markExpired(fixtureServerId);
await tester.pump();
expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(find.byType(JellyfinBrowser), findsOneWidget);
});
testWidgets('Library tab: the last pick comes back; offline it only browses',
(tester) async {
await setSize(tester, const Size(1800, 1000));
final (accounts, _) = await linkedAccounts();
const pick = JellyfinMedia(
serverId: fixtureServerId,
baseUrl: 'https://example.invalid',
itemId: 'b18826be994070a79f9b52bc0f66f052',
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6');
await accounts.rememberPick(pick);
final c = MediaPickerController();
addTearDown(c.dispose);
await restoreLastPick(c, accounts, () => true);
final put = <PickedMedia>[];
Future<void> pump({required bool online}) => tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: LibraryTab(
controller: c,
accounts: accounts,
online: online,
isHost: false,
onPutUp: (m) async {
put.add(m);
return null;
},
),
)));
await pump(online: false);
await settle(tester);
// The saved pick comes back on the Jellyfin source.
expect(c.selection, pick);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(find.text('Join or host to watch together'), findsOneWidget);
final go = find.byKey(const ValueKey('swap-go'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
await pump(online: true);
await settle(tester);
expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
await tester.tap(go);
await settle(tester);
expect(put, [pick]);
tester.view.physicalSize = const Size(800, 900);
await settle(tester, 2);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+2 -79
View File
@@ -6,12 +6,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/jellyfin/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/screens/session/swap_panel.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
@@ -437,9 +437,7 @@ void main() {
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
seasonId: season2Id),
]));
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
await tester.pumpWidget(app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {})));
await settle(tester);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
@@ -516,80 +514,5 @@ void main() {
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
expect(link, 1);
});
testWidgets('swap panel: the shared picker, File for everyone, link relay choice', (tester) async {
await setSize(tester, const Size(1400, 900));
final (accounts, _) = await linkedAccounts();
final c = MediaPickerController(source: MediaKind.link);
addTearDown(c.dispose);
final swapped = <PickedMedia>[];
var closed = 0;
await tester.pumpWidget(app(SwapPanel(
controller: c,
accounts: accounts,
isHost: false,
onSwap: (m) async {
swapped.add(m);
return null;
},
onDone: () => closed++,
)));
await settle(tester);
// A guest's file goes through the server's relay.
expect(find.text('File'), findsOneWidget);
expect(find.text('Jellyfin'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
await tester.pump();
await tester.tap(find.text('Through me'));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('swap-go')));
await settle(tester);
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv', relay: LinkRelay.me)]);
expect(closed, 1);
// The Jellyfin source is the library browser; a pick goes up as is.
await tester.tap(find.text('Jellyfin'));
await settle(tester);
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
await tester.pumpWidget(app(SwapPanel(
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x')));
await settle(tester);
expect(find.text('File'), findsOneWidget);
await tester.tap(find.text('File'));
await settle(tester);
expect(find.text('Friends stream it from your computer.'), findsOneWidget);
});
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
(tester) async {
await setSize(tester, const Size(1600, 1000));
final (accounts, _) = await linkedAccounts();
// Somewhere else remembered: the series start wins.
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()]));
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
.cast<String, dynamic>());
final pick = pickForRef(accounts,
JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!;
final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(SwapPanel(
controller: c,
accounts: accounts,
isHost: false,
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
onSwap: (_) async => null,
)));
await settle(tester, 10);
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
isTrue);
expect(find.text('Put up: ${pick.label}'), findsOneWidget);
final row = tester.widget<Material>(find
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
.first);
expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent),
reason: 'the episode playing is highlighted');
});
});
}
+242
View File
@@ -0,0 +1,242 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/home_rows.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart';
final now = DateTime.utc(2026, 10, 8, 12);
DateTime daysAgo(num d) => now.subtract(Duration(minutes: (d * 24 * 60).round()));
JellyfinItem ep(String id, String series,
{DateTime? played, int position = 0, int? season, int? number, bool done = false}) =>
JellyfinItem(
id: id,
name: id,
type: 'Episode',
seriesId: series,
seriesName: 'Series $series',
parentIndexNumber: season,
indexNumber: number,
userData: JellyfinUserData(played: done, playbackPositionTicks: position, lastPlayedDate: played),
);
JellyfinItem movie(String id, {DateTime? played, int position = 0}) => JellyfinItem(
id: id,
name: id,
type: 'Movie',
userData: JellyfinUserData(playbackPositionTicks: position, lastPlayedDate: played),
);
List<String> ids(List<JellyfinItem> l) => [for (final i in l) i.id];
void main() {
group('Continue row', () {
test('one card per series: resume point and next up merged', () {
final out = mergeContinue(
resume: [ep('a3', 'A', played: daysAgo(1), position: 5)],
nextUp: [ep('a4', 'A'), ep('b2', 'B')],
recentlyPlayed: [ep('b1', 'B', played: daysAgo(2), done: true)],
now: now,
);
expect(ids(out), ['a3', 'b2']);
});
test('the resume item only when it is the latest activity in its series', () {
// Resumed E3 three days ago, then finished E4 yesterday: next up wins.
final out = mergeContinue(
resume: [ep('a3', 'A', played: daysAgo(3), position: 5)],
nextUp: [ep('a5', 'A')],
recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)],
now: now,
);
expect(ids(out), ['a5']);
// No next up (last episode): the resume point stays.
expect(
ids(mergeContinue(
resume: [ep('a3', 'A', played: daysAgo(3), position: 5)],
nextUp: const [],
recentlyPlayed: [ep('a4', 'A', played: daysAgo(1), done: true)],
now: now,
)),
['a3']);
});
test('ordered by when the series was last played; newest resume point of a series', () {
final out = mergeContinue(
resume: [
ep('a1', 'A', played: daysAgo(5), position: 5),
movie('m', played: daysAgo(0.5), position: 5),
ep('a2', 'A', played: daysAgo(4), position: 5),
],
nextUp: [ep('c1', 'C'), ep('b1', 'B')],
recentlyPlayed: [
ep('b0', 'B', played: daysAgo(2), done: true),
ep('c0', 'C', played: daysAgo(3), done: true),
],
now: now,
);
expect(ids(out), ['m', 'b1', 'c1', 'a2']);
});
test('series untouched for over 60 days drop out, unless nothing else is left', () {
final stale = ep('old', 'O', played: daysAgo(90), position: 5);
final unknown = ep('u1', 'U'); // next up with no play date known
expect(
ids(mergeContinue(
resume: [stale, ep('a1', 'A', played: daysAgo(10), position: 5)],
nextUp: [unknown],
now: now)),
['a1']);
expect(ids(mergeContinue(resume: [stale], nextUp: [unknown], now: now)), ['old', 'u1']);
expect(mergeContinue(resume: const [], nextUp: const [], now: now), isEmpty);
});
test('a dismissed series stays out until it is played again', () {
List<String> run(DateTime playedAt) => ids(mergeContinue(
resume: [ep('a1', 'A', played: playedAt, position: 5), ep('b1', 'B', played: daysAgo(1), position: 5)],
nextUp: const [],
dismissed: {'A': daysAgo(2)},
now: now,
));
expect(run(daysAgo(3)), ['b1']);
expect(run(daysAgo(0.5)), ['a1', 'b1']);
});
test('continueKey: the series, else the item', () {
expect(continueKey(ep('e', 'S')), 'S');
expect(continueKey(movie('m')), 'm');
});
});
group('Latest row', () {
JellyfinItem added(String id, {String? series, String type = 'Episode', DateTime? aired, DateTime? at, int? n}) =>
JellyfinItem(
id: id,
name: id,
type: type,
seriesId: series,
seriesName: series == null ? null : 'Show $series',
seriesPrimaryImageTag: series == null ? null : 'tag-$series',
parentIndexNumber: series == null ? null : 1,
indexNumber: n,
premiereDate: aired,
dateCreated: at,
);
test('re-releases: added more than 30 days after airing', () {
expect(isReRelease(added('x', aired: daysAgo(400), at: daysAgo(1))), isTrue);
expect(isReRelease(added('x', aired: daysAgo(20), at: daysAgo(1))), isFalse);
expect(isReRelease(added('x', aired: null, at: daysAgo(1))), isFalse);
expect(isReRelease(added('x', aired: daysAgo(400), at: null)), isFalse);
// Added before it aired (pre-release): not a re-release.
expect(isReRelease(added('x', aired: daysAgo(1), at: daysAgo(400))), isFalse);
});
test('episodes become series cards: one new shows S1E1, more show "N new"', () {
final cards = latestCards([
added('a2', series: 'A', n: 2, aired: daysAgo(1), at: daysAgo(0)),
added('m', type: 'Movie', aired: daysAgo(3), at: daysAgo(1)),
added('a1', series: 'A', n: 1, aired: daysAgo(8), at: daysAgo(1)),
added('b1', series: 'B', n: 1, aired: daysAgo(2), at: daysAgo(2)),
]);
expect([for (final c in cards) c.display.name], ['Show A', 'm', 'Show B']);
expect([for (final c in cards) c.subtitle], ['2 new', anything, 'S1E1']);
// Two new: the card is the series; one new: selecting it is the episode.
expect(cards[0].item.id, 'A');
expect(cards[0].item.kind, JellyfinItemKind.series);
expect(cards[2].item.id, 'b1');
expect(cards[2].display.image(JellyfinImageType.primary)!.itemId, 'B');
expect(cards[2].display.image(JellyfinImageType.primary)!.tag, 'tag-B');
});
test('re-releases are left out when hidden, kept otherwise', () {
final items = [
added('new', series: 'A', n: 5, aired: daysAgo(1), at: daysAgo(0)),
added('old1', series: 'R', n: 1, aired: daysAgo(3000), at: daysAgo(0)),
added('old2', series: 'R', n: 2, aired: daysAgo(3000), at: daysAgo(0)),
];
expect([for (final c in latestCards(items)) c.display.name], ['Show A']);
expect([for (final c in latestCards(items, hideReReleases: false)) c.display.name], ['Show A', 'Show R']);
});
test('grouped series from the server keep their "N new"', () {
const s = JellyfinItem(id: 's', name: 'Grouped', type: 'Series', isFolder: true, childCount: 4);
expect(latestCards([s]).single.subtitle, '4 new');
});
});
group('Link source', () {
RecentLink link(String url, [int day = 1]) =>
RecentLink(url: url, title: url, at: DateTime.utc(2026, 1, day));
test('recent links: newest first, deduped, at most 12, round-trip', () {
var list = <RecentLink>[];
for (var i = 0; i < 15; i++) {
list = pushRecentLink(list, link('https://x/$i'));
}
expect(list, hasLength(maxRecentLinks));
expect(list.first.url, 'https://x/14');
list = pushRecentLink(list, link('https://x/10', 9));
expect(list.first.url, 'https://x/10');
expect(list.where((l) => l.url == 'https://x/10'), hasLength(1));
final back = decodeRecentLinks(encodeRecentLinks(list));
expect([for (final l in back) l.url], [for (final l in list) l.url]);
expect(back.first.at.isAtSameMomentAs(DateTime.utc(2026, 1, 9)), isTrue);
expect(decodeRecentLinks('nope'), isEmpty);
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
});
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async {
Uri? asked;
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async {
asked = uri;
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'});
});
expect(asked!.host, 'www.youtube.com');
expect(asked!.path, '/oembed');
expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'});
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg'));
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
get: (_) => throw StateError('no lookup for plain links'));
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline'));
expect((down!.title, down.subtitle), ('abc', 'youtu.be'));
expect(await previewLink('not a link'), isNull);
});
test('ago text', () {
final t = DateTime.utc(2026, 10, 8);
expect(agoText(t.subtract(const Duration(hours: 3)), t), 'today');
expect(agoText(t.subtract(const Duration(days: 1)), t), 'yesterday');
expect(agoText(t.subtract(const Duration(days: 3)), t), '3 days ago');
expect(agoText(t.subtract(const Duration(days: 9)), t), 'last week');
expect(agoText(t.subtract(const Duration(days: 40)), t), '5 weeks ago');
});
});
test('now playing file: only a plain file named like the last one put up', () {
expect(nowPlayingFile({'name': 'b.mkv', 'size': 1}, '/v/b.mkv'), '/v/b.mkv');
expect(nowPlayingFile({'name': 'b.mkv', 'url': 'http://x/b.mkv'}, '/v/b.mkv'), isNull);
expect(nowPlayingFile({'name': 'b.mkv', 'jellyfin': {}}, '/v/b.mkv'), isNull);
expect(nowPlayingFile({'name': 'c.mkv'}, '/v/b.mkv'), isNull);
expect(nowPlayingFile(null, '/v/b.mkv'), isNull);
expect(nowPlayingFile({'name': 'b.mkv'}, r'C:\v\b.mkv'), r'C:\v\b.mkv');
});
test('controller: a Jellyfin pick selects its card', () {
final c = MediaPickerController();
addTearDown(c.dispose);
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
c.pickJellyfin(m);
expect(c.source, MediaKind.jellyfin);
expect(c.selected!.itemId, 'i');
c.select(null);
expect(c.selected, isNull);
expect(c.jellyfin, m);
});
}
+392
View File
@@ -0,0 +1,392 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/details_panel.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id;
import 'jellyfin_browser_test.dart' show fixtureNow, settle;
const bsSeries = 'e9aec9c14a1fcab775111844112b4cf7';
const bsEpisode = 'b18826be994070a79f9b52bc0f66f052';
const bsSeason = 'fb0b77cfda15445f9319ed9d753dba23';
const s2e2 = 'f5600f84e2c02c1f30442bf679dcbb1d';
JellyfinHttpResponse page(List<Object> items) =>
JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length}));
/// The fake server, plus: Arifureta in "Latest" (2 new), Black Summoner's
/// series, season and episode, and watched-state calls.
FakeJellyfin libraryFake() {
final fake = FakeJellyfin();
final arifureta = jsonDecode(jellyfinFixture('series_detail')) as Map<String, dynamic>;
// Added when it aired: not a re-release.
fake.on('GET', '/Items/Latest', (_, _) => JellyfinHttpResponse(200, jsonEncode([
{...arifureta, 'ChildCount': 2, 'DateCreated': arifureta['PremiereDate']},
])));
final episode = jsonDecode(jellyfinFixture('episode_detail')) as Map<String, dynamic>;
fake.on('GET', '/Items/$bsSeries',
(_, _) => JellyfinHttpResponse(200, jsonEncode({'Id': bsSeries, 'Name': 'Black Summoner', 'Type': 'Series'})));
fake.on('GET', '/Shows/$bsSeries/Seasons', (_, _) => page([
{'Id': bsSeason, 'Name': 'Season 1', 'Type': 'Season', 'IndexNumber': 1, 'SeriesId': bsSeries},
]));
fake.on('GET', '/Shows/$bsSeries/Episodes', (_, _) => page([episode]));
// Arifureta's episodes load on their own too (a pick restored by id).
for (final e in (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List) {
fake.on('GET', '/Items/${e['Id']}', (_, _) => JellyfinHttpResponse(200, jsonEncode(e)));
}
const ud = '{"Played":true,"PlaybackPositionTicks":0,"PlayCount":1}';
fake.on('POST', '/UserPlayedItems/$bsEpisode', (_, _) => const JellyfinHttpResponse(200, ud));
return fake;
}
class FilesStub extends StatelessWidget {
final bool online, relayed;
final Future<String?> Function(String path) onPlay;
const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay});
@override
Widget build(BuildContext context) => Column(children: [
Text('files online=$online relayed=$relayed'),
TextButton(
key: const ValueKey('stub-play'),
onPressed: online ? () => onPlay('/v/a.mkv') : null,
child: const Text('play'),
),
]);
}
class Harness {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final FakeJellyfin fake;
final put = <PickedMedia>[];
final recents = RecentLinks(read: () async => null, write: (_) async {});
Harness(this.controller, this.accounts, this.fake);
}
Future<Harness> pumpLibrary(
WidgetTester tester,
Size size, {
bool online = true,
bool isHost = false,
MediaKind source = MediaKind.jellyfin,
Harness? reuse,
BrowseStart? start,
JellyfinMedia? pick,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final Harness h;
if (reuse != null) {
h = reuse;
} else {
final (accounts, fake) = await linkedAccounts(http: libraryFake());
final c = MediaPickerController(source: source, jellyfin: pick);
if (pick != null) c.source = source;
addTearDown(c.dispose);
h = Harness(c, accounts, fake);
}
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: LibraryTab(
// A new tab each time, like the shell after "Change video".
key: UniqueKey(),
controller: h.controller,
accounts: h.accounts,
online: online,
isHost: isHost,
jellyfinStart: start,
recentLinks: h.recents,
now: () => fixtureNow,
oembed: (uri) async => jsonEncode({
'title': 'A video',
'author_name': 'Chan',
}),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
h.put.add(m);
return null;
},
),
),
));
await settle(tester, 10);
return h;
}
Finder arifuretaCard() => find.byKey(const ValueKey('card-$arifuretaId')).first;
Future<void> doubleTap(WidgetTester tester, Finder f) async {
await tester.tap(f);
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(f);
await settle(tester, 10);
}
void main() {
testWidgets('source switch fades through; library chips sit in the header', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000));
expect(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
await tester.tap(find.text('Link'));
await tester.pump(const Duration(milliseconds: 60));
// Fade through: the new view waits until the old one is gone.
final incoming = tester.widget<FadeTransition>(
find.ancestor(of: find.text('Watch from a link'), matching: find.byType(FadeTransition)).first);
expect(incoming.opacity.value, 0);
await settle(tester, 10);
expect(find.text('Continue'), findsNothing);
expect(find.text('Watch from a link'), findsOneWidget);
expect(h.controller.source, MediaKind.link);
await tester.tap(find.text('Files'));
await settle(tester, 10);
expect(find.text('files online=true relayed=true'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]);
await tester.tap(find.text('Jellyfin'));
await settle(tester, 10);
// A chip opens its library; again goes back home.
await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')));
await settle(tester, 10);
expect(find.text('218 items'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('library-a656b907eb3a73532e40e44b968d0225')));
await settle(tester, 10);
expect(find.text('Continue'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('select a card: the panel slides in; episodes pick what goes up', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000));
expect(find.byKey(const ValueKey('jf-details')), findsNothing);
await tester.tap(arifuretaCard());
await settle(tester, 10);
final panel = find.byKey(const ValueKey('jf-details'));
expect(panel, findsOneWidget);
// Docked at the right, 400 px.
expect(tester.getSize(panel).width, detailsPanelWidth);
expect(tester.getTopLeft(panel).dx, 1600 - detailsPanelWidth);
expect(h.controller.selected!.itemId, arifuretaId);
// Next up is S2E4; the list is its season.
expect(find.text('Put it up · S2E4'), findsOneWidget);
expect(find.byKey(const ValueKey('details-episode-$s2e2')), findsOneWidget);
await tester.ensureVisible(find.byKey(const ValueKey('details-episode-$s2e2')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('details-episode-$s2e2')));
await settle(tester);
expect(find.text('Put it up · S2E2'), findsOneWidget);
await tester.drag(find.byKey(const ValueKey('details-list')), const Offset(0, 2000));
await settle(tester);
await tester.tap(find.byKey(const ValueKey('details-put-up')));
await settle(tester);
final m = h.put.single as JellyfinMedia;
expect((m.serverId, m.itemId, m.title), (fixtureServerId, s2e2, 'Burning Light'));
expect(m.subtitle, endsWith('S2E2'));
// Another card: the panel's content cross-fades.
await tester.tap(find.byKey(const ValueKey('card-$bsEpisode')));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text("Arifureta: From Commonplace to World's Strongest"), findsWidgets,
reason: 'the old panel is still fading out');
await settle(tester, 10);
expect(find.text('Put it up · S2E2'), findsNothing);
expect(find.text('Put it up · S1E6'), findsOneWidget);
// Close: the panel slides away and the selection is gone.
await tester.tap(find.byKey(const ValueKey('details-close')));
await settle(tester, 10);
expect(h.controller.selected, isNull);
expect(tester.getSize(find.byType(ClipRect).first).width, isNot(detailsPanelWidth));
expect(tester.takeException(), isNull);
});
testWidgets('double-click a card puts it up (a series: its next episode)', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000));
await doubleTap(tester, arifuretaCard());
final m = h.put.single as JellyfinMedia;
expect(m.itemId, '56d6b3b65c83213fcbaeaf85c9239189'); // S2E4
expect(h.controller.selected!.itemId, arifuretaId);
await doubleTap(tester, find.byKey(const ValueKey('card-$bsEpisode')));
expect((h.put.last as JellyfinMedia).itemId, bsEpisode);
expect(tester.takeException(), isNull);
});
testWidgets('mark watched and remove from Continue reload the rows', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000));
final row = find.byKey(const ValueKey('row-continue'));
expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('card-$bsEpisode')));
await settle(tester, 10);
expect(find.text('Put it up · S1E6'), findsOneWidget);
final resumes = h.fake.count('GET', '/UserItems/Resume');
await tester.tap(find.byKey(const ValueKey('details-watched')));
await settle(tester, 10);
expect(h.fake.count('POST', '/UserPlayedItems/$bsEpisode'), 1);
expect(h.fake.count('GET', '/UserItems/Resume'), greaterThan(resumes), reason: 'rows reload');
expect(find.byKey(const ValueKey('details-remove-continue')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('details-remove-continue')));
await settle(tester, 10);
expect(h.accounts.continueDismissed(fixtureServerId), contains(bsSeries));
expect(find.descendant(of: row, matching: find.text('Black Summoner')), findsNothing);
expect(find.descendant(of: row, matching: find.text("JoJo's Bizarre Adventure")), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('offline: everything browses, nothing goes up', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), online: false);
await tester.tap(arifuretaCard());
await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget);
expect(find.text(offlinePutUpHint), findsOneWidget);
await doubleTap(tester, arifuretaCard());
expect(h.put, isEmpty);
await tester.tap(find.text('Link'));
await settle(tester, 10);
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10);
final linkGo = find.byKey(const ValueKey('link-put-up'));
expect(tester.widget<ButtonStyleButton>(linkGo).onPressed, isNull);
expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)),
findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works');
await tester.tap(find.text('Files'));
await settle(tester, 10);
expect(find.text('files online=false relayed=true'), findsOneWidget);
expect(h.put, isEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('link: preview, how the others get it, put up, recent links, Ctrl+V', (tester) async {
final h = await pumpLibrary(tester, const Size(1400, 1000), source: MediaKind.link);
expect(find.text('How the others get it'), findsOneWidget);
const url = 'https://www.youtube.com/watch?v=abc';
await tester.enterText(find.byKey(const ValueKey('link-field')), url);
await settle(tester, 10);
expect(find.descendant(of: find.byKey(const ValueKey('link-preview')), matching: find.text('A video')),
findsOneWidget);
expect(find.text('youtube.com · Chan'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-mode-me')));
await settle(tester);
expect(h.controller.linkRelay, LinkRelay.me);
expect(find.text(linkRelayHelp(LinkRelay.me)), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(h.put, [const LinkMedia(url, relay: LinkRelay.me)]);
expect(h.recents.links.value.single.title, 'A video');
expect(find.byKey(const ValueKey('recent-link-$url')), findsOneWidget);
// A recent link refills the field.
await tester.enterText(find.byKey(const ValueKey('link-field')), '');
await settle(tester, 10);
await tester.tap(find.byKey(const ValueKey('recent-link-$url')));
await settle(tester, 10);
expect(h.controller.link.text, url);
// Ctrl+V outside a field, on another source: the link lands in Link.
await tester.tap(find.text('Jellyfin'));
await settle(tester, 10);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.getData') return {'text': 'https://cdn.example/b.mkv'};
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
FocusManager.instance.primaryFocus?.unfocus();
Focus.of(tester.element(find.byKey(const ValueKey('media-source')))).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await settle(tester, 10);
expect(h.controller.source, MediaKind.link);
expect(h.controller.link.text, 'https://cdn.example/b.mkv');
expect(tester.takeException(), isNull);
});
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
expect(find.text('files online=true relayed=false'), findsOneWidget);
});
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
final (accounts, _) = await linkedAccounts(http: libraryFake());
const pick = JellyfinMedia(
serverId: fixtureServerId,
baseUrl: 'https://example.invalid',
itemId: bsEpisode,
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6');
await accounts.rememberPick(pick);
final c = MediaPickerController();
addTearDown(c.dispose);
await restoreLastPick(c, accounts, () => true);
expect(c.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin());
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
// Change video: the series of the episode playing, its season chosen
// and the episode highlighted.
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
.cast<String, dynamic>());
final h2 = await pumpLibrary(tester, const Size(1600, 1000),
start: BrowseStart.seriesOf(fixtureServerId, ep),
pick: JellyfinMedia(serverId: fixtureServerId, baseUrl: 'x', itemId: ep.id, title: ep.name));
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected, isTrue);
final tile = tester.widget<Material>(find
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
.first);
expect((tile.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent));
expect(find.text('Put it up · S2E1'), findsOneWidget);
expect(h2.controller.selected!.itemId, ep.id);
expect(tester.takeException(), isNull);
});
for (final size in const [Size(2560, 1080), Size(1100, 700), Size(900, 600)]) {
testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}, panel open, every source',
(tester) async {
final h = await pumpLibrary(tester, size);
expect(tester.takeException(), isNull);
await tester.tap(arifuretaCard());
await settle(tester, 10);
expect(tester.takeException(), isNull);
final panel = find.byKey(const ValueKey('jf-details'));
expect(panel, findsOneWidget);
// Under 1000 px the panel floats over the library at the right edge.
expect(tester.getTopRight(panel).dx, size.width);
h.controller.link.text = 'https://cdn.example/some/long/path/Movie.Name.2024.1080p.mkv';
for (final s in [MediaKind.link, MediaKind.file, MediaKind.jellyfin]) {
h.controller.source = s;
await settle(tester, 10);
expect(tester.takeException(), isNull, reason: '$s at $size');
}
h.controller.select(null);
await settle(tester, 10);
expect(tester.takeException(), isNull);
});
}
}
+321
View File
@@ -0,0 +1,321 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/controls_probe.dart';
import 'package:watch_together/src/controls/player_settings.dart';
import 'package:watch_together/src/controls/uosc/controls.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/shell/avatar.dart';
import 'package:watch_together/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
// The player's ⚙ card (both designs, never a media_kit `Video`), the
// Settings pages, the host flag in the party UI and invite addresses.
Widget app(Widget child) => MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child));
void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
}
Future<void> pick(WidgetTester tester, String key, String item) async {
await tester.tap(find.byKey(ValueKey(key)));
await tester.pumpAndSettle();
await tester.tap(find.text(item).last);
await tester.pumpAndSettle();
}
void main() {
final s = AppSettings.instance;
final coord = SessionState.instance.coordinator;
tearDown(() {
s.mpvControls.value = false;
s.progressLine.value = true;
s.shaderPreset.value = ShaderPreset.off;
s.shaderQuality.value = ShaderQuality.hq;
s.hwdecMode.value = HwdecMode.auto;
coord.playback.tracks.value = const [];
coord.serverState.value = null;
coord.hostFile = null;
SessionState.instance.route.value = AppRoute.home;
SessionState.instance.recents.value = const [];
});
group('⚙ player settings', () {
testWidgets('standard controls: the gear opens every player setting, changes apply live', (tester) async {
bigWindow(tester);
coord.playback.tracks.value = const [
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true),
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'),
];
await tester.pumpWidget(app(const Align(alignment: Alignment.bottomRight, child: PlayerSettingsButton())));
await tester.tap(find.byKey(const ValueKey('player-settings')));
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('player-settings-card'));
expect(card, findsOneWidget);
for (final label in [
'Player design',
'Progress line when hidden',
'Upscaling shader',
'Hardware decoding',
'Subtitles',
'Audio',
]) {
expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label);
}
// Subtitles off (none selected), audio on the selected track.
expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')),
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')),
findsOneWidget);
await pick(tester, 'player-design', 'uosc');
expect(s.mpvControls.value, isTrue);
await tester.tap(find.byKey(const ValueKey('progress-line')));
await tester.pump();
expect(s.progressLine.value, isFalse);
await pick(tester, 'shader-preset', 'Mode B');
expect(s.shaderPreset.value, ShaderPreset.b);
await pick(tester, 'shader-quality', 'Fast');
expect(s.shaderQuality.value, ShaderQuality.fast);
await pick(tester, 'hwdec', 'Off');
expect(s.hwdec.value, 'no');
// Esc closes it.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(card, findsNothing);
});
testWidgets('uosc controls: the gear opens the card and keeps the controls up', (tester) async {
bigWindow(tester);
s.mpvControls.value = true;
await tester.pumpWidget(app(UoscControls(
hideAfter: const Duration(seconds: 1),
timeline: (context, expanded) => const SizedBox(height: 30),
right: const [UoscPlayerSettingsButton()],
)));
final controls = tester.state<UoscControlsState>(find.byType(UoscControls));
await tester.tap(find.byKey(const ValueKey('uosc-player-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget);
expect(find.text('uosc'), findsOneWidget, reason: 'the current design');
await tester.pump(const Duration(seconds: 3));
expect(controls.visible, isTrue, reason: 'held while the card is open');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 2));
expect(controls.visible, isFalse);
});
testWidgets('uosc: hidden controls keep the line unless the setting is off', (tester) async {
await tester.pumpWidget(app(UoscControls(
hideAfter: const Duration(milliseconds: 100),
timeline: (context, expanded) => const SizedBox(height: 2),
)));
double opacity() =>
tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('uosc-timeline-wrap'))).opacity;
await tester.pump(const Duration(milliseconds: 200));
expect(opacity(), 1, reason: 'hidden, line on');
s.setProgressLine(false);
await tester.pump();
expect(opacity(), 0, reason: 'hidden, line off');
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(400, 300));
addTearDown(mouse.removePointer);
await mouse.moveTo(const Offset(410, 300));
await tester.pump();
expect(opacity(), 1, reason: 'controls up: the full timeline');
await tester.pump(const Duration(milliseconds: 200));
});
});
testWidgets('standard controls: the probe follows media_kit\'s show / hide rule', (tester) async {
final shown = ValueNotifier(false);
addTearDown(shown.dispose);
await tester.pumpWidget(app(ControlsHoverProbe(
shown: shown,
hideAfter: const Duration(seconds: 3),
child: const SizedBox.expand(),
)));
await tester.pump();
expect(shown.value, isTrue, reason: 'up on mount');
await tester.pump(const Duration(seconds: 3));
expect(shown.value, isFalse, reason: 'down 3 s later');
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: const Offset(400, 300));
addTearDown(mouse.removePointer);
await mouse.moveTo(const Offset(420, 300));
await tester.pump();
expect(shown.value, isTrue, reason: 'mouse movement');
await tester.pump(const Duration(seconds: 3));
expect(shown.value, isFalse);
});
group('Settings pages', () {
Future<void> pumpSettings(WidgetTester tester, SettingsSection section,
{Future<AnimeFlowMe> Function()? me, Size size = const Size(1280, 1400)}) async {
bigWindow(tester, size);
await tester.pumpWidget(app(SettingsTab(
section: section,
onSection: (_) {},
updates: UpdateController(currentVersion: '0.0.0-dev'),
animeFlowMe: me ?? () async => throw const AnimeFlowException(401, AnimeFlowErrorCode.missingToken, 'x'),
)));
await tester.pumpAndSettle();
}
testWidgets('every section renders as cards, with icons in the nav', (tester) async {
for (final section in SettingsSection.values) {
await pumpSettings(tester, section);
expect(find.text(section.label), findsWidgets, reason: section.name);
expect(tester.takeException(), isNull, reason: section.name);
}
expect(SettingsSection.values.map((s) => s.label), [
'Profile',
'Party & network',
'Player',
'Jellyfin & integrations',
'Notifications & sounds',
'Updates',
'Keyboard shortcuts',
'About',
]);
expect(find.byIcon(Icons.hub_outlined), findsOneWidget);
});
testWidgets('Player: the same rows as the ⚙, plus a pointer to it', (tester) async {
await pumpSettings(tester, SettingsSection.player);
expect(find.textContaining("also in the player's ⚙"), findsOneWidget);
for (final key in ['player-design', 'progress-line', 'shader-preset', 'hwdec']) {
expect(find.byKey(ValueKey(key)), findsOneWidget, reason: key);
}
expect(find.byKey(const ValueKey('audio-track')), findsNothing, reason: 'tracks only in the player');
expect(find.text('Standard'), findsOneWidget);
await pick(tester, 'player-design', 'uosc');
expect(s.mpvControls.value, isTrue);
});
testWidgets('Party & network: host defaults and recent servers with remove', (tester) async {
SessionState.instance.recents.value = const [
RecentServer(hosted: false, host: 'vps.example', port: '8080', secret: 'pw'),
RecentServer(hosted: true, host: '127.0.0.1', port: '9000'),
];
await pumpSettings(tester, SettingsSection.network);
expect(find.text('Default port'), findsOneWidget);
expect(find.text('vps.example:8080'), findsOneWidget);
expect(find.text('Hosted on port 9000'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('host-port')), '70000');
await tester.pump();
expect(find.text('A number from 1 to 65535'), findsOneWidget);
await tester.enterText(find.byKey(const ValueKey('host-port')), '9100');
await tester.pump();
expect(SessionState.instance.lastForm.hostPort, '9100');
await tester.tap(find.descendant(
of: find.byKey(const ValueKey('recent-vps.example:8080')), matching: find.byTooltip('Remove')));
await tester.pumpAndSettle();
expect(find.text('vps.example:8080'), findsNothing);
SessionState.instance.lastForm.hostPort = '8080';
});
testWidgets('Integrations: AnimeFlow says who you are connected as, or why not', (tester) async {
await pumpSettings(tester, SettingsSection.integrations,
me: () async => const AnimeFlowMe(
userId: 'u1', name: 'foxyz', scopes: {AnimeFlowScope.view, AnimeFlowScope.request}));
expect(find.text('connected as foxyz · can view, request'), findsOneWidget);
// A fresh tree: the card keeps its first answer otherwise.
await tester.pumpWidget(const SizedBox());
await pumpSettings(tester, SettingsSection.integrations,
me: () async => throw const AnimeFlowException(
401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow'));
expect(find.text('No linked Jellyfin account for AnimeFlow'), findsOneWidget);
});
testWidgets('narrow: icon chips instead of the side nav', (tester) async {
await pumpSettings(tester, SettingsSection.network, size: const Size(700, 1000));
expect(find.byType(ChoiceChip), findsWidgets);
expect(find.byType(ListTile).evaluate().where((e) => e.widget.key == const ValueKey('settings-profile')),
isEmpty, reason: 'no side nav');
final chip = tester.widget<ChoiceChip>(find.byKey(const ValueKey('settings-network')));
expect(chip.selected, isTrue);
expect((chip.avatar! as Icon).icon, Icons.hub_outlined);
expect(find.text('Default port'), findsOneWidget);
});
});
group('party', () {
test('invite addresses: LAN IPv4 with the port, private first, no loopback', () {
final list = inviteAddresses([
InternetAddress('127.0.0.1'),
InternetAddress('100.64.1.2'),
InternetAddress('192.168.1.20'),
InternetAddress('169.254.3.4'),
InternetAddress('::1'),
InternetAddress('10.0.0.5'),
InternetAddress('192.168.1.20'),
], '8080');
expect(list, ['192.168.1.20:8080', '10.0.0.5:8080', '100.64.1.2:8080']);
expect(inviteAddresses(const [], '8080'), isEmpty);
});
void online(List<MemberView> members) {
coord.serverState.value = StateBroadcast(
eventId: 1,
transport: 'held',
position: 0,
serverTime: DateTime.now().toUtc(),
mediaId: 0,
members: members,
);
SessionState.instance.route.value = AppRoute.session;
}
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
online(const [
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true),
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false),
]);
final avatars = {for (final a in partyAvatars(coord)) a.name: a};
expect(avatars['Hana']!.host, isTrue);
expect(avatars['Bob']!.host, isFalse);
bigWindow(tester);
await tester.pumpWidget(app(const Center(child: MembersCard())));
await tester.pumpAndSettle();
expect(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.text('host')),
findsOneWidget);
expect(find.descendant(of: find.byKey(const ValueKey('member-m2')), matching: find.text('host')),
findsNothing);
// The host cannot be kicked: no Kick in its menu.
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-m1')), matching: find.byTooltip('More')));
await tester.pumpAndSettle();
expect(find.text('Kick'), findsNothing);
});
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]);
coord.hostFile = (_) async => null;
SessionState.instance.lastForm.hostPort = '8080';
bigWindow(tester);
await tester.pumpWidget(app(Center(
child: MembersCard(lanAddresses: (port) async => ['192.168.1.20:$port', '10.0.0.5:$port']),
)));
await tester.pumpAndSettle();
expect(find.text('Copy invite address'), findsOneWidget);
expect(find.text('192.168.1.20:8080'), findsOneWidget);
expect(find.text('10.0.0.5:8080'), findsOneWidget);
expect(find.textContaining('port 8080 forwarded'), findsOneWidget);
expect(find.textContaining('VPS'), findsOneWidget);
});
});
}
+8 -4
View File
@@ -301,17 +301,21 @@ void main() {
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
final at = DateTime.now();
final names = ['Bob', 'Bob Jr'];
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who);
// The actor is exact: "Bob" is not mistaken for "Bob Jr".
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at, actor: 'Bob Jr'), who);
expect(chat.who!.name, 'Bob Jr');
expect(chat.text, 'hi there');
expect(chat.chat, isTrue);
final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at), names, who);
final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at, actor: 'Bob'), who);
expect(joined.who!.name, 'Bob');
expect(joined.text, 'joined');
expect(joined.icon, Icons.login);
final app = shellToastFor(ToastItem(id: 3, text: 'Video froze — reloaded', at: at), names, who);
// A name that is a prefix of a sentence is not guessed without an actor.
final app = shellToastFor(ToastItem(id: 3, text: 'Bob joined', at: at), who);
expect(app.who, isNull);
expect(app.text, 'Bob joined');
final froze = shellToastFor(ToastItem(id: 4, text: 'Video froze — reloaded', at: at), who);
expect(froze.who, isNull);
});
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
+9 -7
View File
@@ -447,7 +447,7 @@ void main() {
expect(find.text('material'), findsOneWidget);
});
testWidgets('Settings: "Experimental: mpv-style controls", off by default', (tester) async {
testWidgets('Settings: "Player design", Standard by default', (tester) async {
final s = AppSettings.instance;
s.mpvControls.value = false;
addTearDown(() => s.mpvControls.value = false);
@@ -464,12 +464,14 @@ void main() {
),
),
));
final toggle = find.byKey(const ValueKey('mpv-controls'));
expect(find.text('Experimental: mpv-style controls'), findsOneWidget);
expect(tester.widget<SwitchListTile>(toggle).value, isFalse);
await tester.tap(toggle);
await tester.pump();
final design = find.byKey(const ValueKey('player-design'));
expect(find.text('Player design'), findsOneWidget);
expect(tester.widget<DropdownButton<bool>>(design).value, isFalse);
await tester.tap(design);
await tester.pumpAndSettle();
await tester.tap(find.text('uosc').last);
await tester.pumpAndSettle();
expect(s.mpvControls.value, isTrue);
expect(tester.widget<SwitchListTile>(toggle).value, isTrue);
expect(tester.widget<DropdownButton<bool>>(design).value, isTrue);
});
}