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

@@ -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)));
}
});
});