1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag
Library: source switch, selectable cards with a details panel, one merged Continue row, Latest grouped per series without re-releases, Link page with previews and recent links, Files embedded. Player: gear popover in both designs, progress line while controls hide, live hwdec. Settings rebuilt as cards. State marks the host member (proto stays 9, unreleased); hosting shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps the outgoing view's state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -609,6 +609,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
onLeave: _confirmLeave,
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -103,8 +103,9 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user