Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
- Status: computed from connection, presence, hold, relay, player facts; no setters (health writes and HealthStatus gone); pill, strip and source line read it; transitions go to the Notifier. - Notifier: noticeFor(event, context) with self presence, alone, window activity, playing and prefs; toasts for joins/leaves/returns/swaps/ Up next adds (with picture)/problems/chat, the rest logged; while sitting out only chat, nudges, starting, Up next (+ joins if chosen); alone no social notices; replayed lines logged; owns its toast timer. - System notifications (flutter_local_notifications: D-Bus on Linux, WinRT on Windows) when the window isn't focused; a click focuses it. - Toasts sit above the control bar and move with it. - Versions: each member's in the members card and avatar tooltip, the server's in the card header; the strip no longer shows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
130c934b9b
commit
629be0d9ed
41 files changed
+2114
-741
No files matched your search
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../controls/session_controls.dart' show controlsBottomHeight;
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
@@ -98,6 +99,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
final _chat = TextEditingController();
|
||||
final _chatFocus = FocusNode();
|
||||
final _playerKey = GlobalKey();
|
||||
/// Whether the full player's controls are up (the player's frame
|
||||
/// reports it): the toasts sit above them.
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
@@ -187,6 +191,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_autoFull = false;
|
||||
});
|
||||
}));
|
||||
// A click on a chat system notification: the window is up, open the
|
||||
// chat.
|
||||
var chatAsks = coord.notifier.chatRequests.peek();
|
||||
_stops.add(effect(() {
|
||||
final asks = coord.notifier.chatRequests.value;
|
||||
if (asks == chatAsks) return;
|
||||
chatAsks = asks;
|
||||
_later(() {
|
||||
if (!_chatOpen && _online.peek()) _toggleChat();
|
||||
});
|
||||
}));
|
||||
// Chat that arrives while the panel is closed.
|
||||
_stops.add(effect(() {
|
||||
final log = coord.feedLog.value;
|
||||
@@ -250,6 +265,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_picker.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
if (_fullscreen) _setWindowFullscreen(false);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -771,16 +787,44 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
),
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: PartyToasts(
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
)),
|
||||
_toasts(full && online),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The toasts, bottom left; over the full player above its control
|
||||
/// bar (the bar's height in the skin and size in use), moving up while
|
||||
/// the controls show and down while they are hidden, so they never
|
||||
/// cover the controls (both skins).
|
||||
Widget _toasts(bool overPlayer) {
|
||||
final toasts = PartyToasts(
|
||||
onOpenChat: () {
|
||||
if (!_chatOpen) _toggleChat();
|
||||
},
|
||||
);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => SignalBuilder(builder: (context) {
|
||||
final s = AppSettings.instance;
|
||||
final bar = controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
return AnimatedPositioned(
|
||||
key: const ValueKey('toasts'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: toastInset,
|
||||
bottom: overPlayer && shown ? bar + toastInset : toastInset,
|
||||
child: child!,
|
||||
);
|
||||
}),
|
||||
child: toasts,
|
||||
);
|
||||
}
|
||||
|
||||
/// The toasts' distance from the body's left and bottom edges (and from
|
||||
/// the player's control bar).
|
||||
static const toastInset = 16.0;
|
||||
|
||||
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
||||
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
||||
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
||||
@@ -852,6 +896,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
onLeave: _confirmLeave,
|
||||
videoBuilder: widget.videoBuilder,
|
||||
windowControls: widget.windowControls,
|
||||
controlsShown: _controlsShown,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,8 @@ class AvatarInfo {
|
||||
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
||||
/// the hover; empty when unknown.
|
||||
final String delivery;
|
||||
/// Their Starsync version (`members[].app`), for the hover; '' unknown.
|
||||
final String app;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -44,6 +46,7 @@ class AvatarInfo {
|
||||
this.self = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
this.app = '',
|
||||
});
|
||||
|
||||
/// Sitting out.
|
||||
@@ -136,6 +139,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
self: self,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
app: m.app,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,6 +336,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
? visible[i].name
|
||||
: "${visible[i].name} can't play this: ${visible[i].error}",
|
||||
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
||||
if (visible[i].app.isNotEmpty) 'Starsync ${visible[i].app}',
|
||||
].join('\n'),
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
|
||||
@@ -8,13 +8,14 @@ import 'motion.dart';
|
||||
|
||||
Color _tone(SourceState s, ColorScheme scheme) => switch (s) {
|
||||
SourceState.ok => scheme.primary,
|
||||
SourceState.slow || SourceState.waiting => Colors.amber,
|
||||
SourceState.slow || SourceState.waiting || SourceState.gone => Colors.amber,
|
||||
SourceState.left => Colors.redAccent.shade200,
|
||||
};
|
||||
|
||||
IconData _icon(Delivery d) => switch (d.state) {
|
||||
SourceState.left => Icons.link_off_rounded,
|
||||
SourceState.waiting => Icons.hourglass_top_rounded,
|
||||
SourceState.gone => Icons.wifi_off_rounded,
|
||||
_ => switch (d.kind) {
|
||||
DeliveryKind.memberRelay => Icons.cell_tower,
|
||||
DeliveryKind.serverFetch => Icons.cloud_download_outlined,
|
||||
@@ -28,8 +29,8 @@ IconData _icon(Delivery d) => switch (d.state) {
|
||||
/// Where the current media comes from and how it reaches the others:
|
||||
/// "Foxyz passes it on through the server", "3 through the server ·
|
||||
/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the
|
||||
/// video's (past the mark: keeping up). Amber when slow or waiting, red
|
||||
/// when the source left.
|
||||
/// video's (past the mark: keeping up). Amber when slow, waiting or the
|
||||
/// source's connection dropped, red when the source left.
|
||||
class SourceLine extends StatelessWidget {
|
||||
final Delivery delivery;
|
||||
const SourceLine({super.key, required this.delivery});
|
||||
@@ -40,7 +41,7 @@ class SourceLine extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final tone = _tone(d.state, scheme);
|
||||
final detail = d.detail;
|
||||
final headroom = d.state == SourceState.left ? null : d.headroom;
|
||||
final headroom = d.state == SourceState.left || d.state == SourceState.gone ? null : d.headroom;
|
||||
return Tooltip(
|
||||
message: d.explain,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 '../session/app_events.dart' show ActionFailed;
|
||||
import '../state/session_state.dart';
|
||||
import 'library_link.dart';
|
||||
import 'motion.dart';
|
||||
@@ -196,21 +197,17 @@ class _LibraryTabState extends State<LibraryTab> {
|
||||
return pick is JellyfinMedia ? add(pick, next: next) : pick as String;
|
||||
}
|
||||
|
||||
/// A card's menu: errors as a snack bar.
|
||||
/// A card's menu: errors as a toast (the Notifier's).
|
||||
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
|
||||
final error = await _queueJellyfin(sel, next: next);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
|
||||
}
|
||||
|
||||
/// Double-click on a card: put it up, errors as a snack bar.
|
||||
/// Double-click on a card: put it up, errors as a toast.
|
||||
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
||||
_c.select(sel);
|
||||
final error = await _putUpJellyfin(sel);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- build
|
||||
|
||||
@@ -121,10 +121,9 @@ class PartyStrip extends StatelessWidget {
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
// The version: always in sight under Settings,
|
||||
// the full one a rest of the mouse away.
|
||||
// The version a rest of the mouse away (also at
|
||||
// the bottom of Settings and in About).
|
||||
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
|
||||
caption: t == ShellTab.settings ? shortVersion() : null,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
@@ -281,13 +280,9 @@ class _ServerLabel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
// The ready counter counts who the presence policy counts (not away,
|
||||
// not gone, able to play it).
|
||||
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
||||
final ready = counted.where((m) => m.ready).length;
|
||||
final stale = coord.stateStale.value;
|
||||
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
|
||||
// counts who the presence policy counts), from the one status.
|
||||
final line = SessionState.instance.coordinator.status.value.party;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -298,11 +293,7 @@ class _ServerLabel extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null
|
||||
? 'connecting…'
|
||||
: stale
|
||||
? 'reconnecting…'
|
||||
: 'ready $ready/${counted.length}',
|
||||
line,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
@@ -373,8 +364,6 @@ class StripTab extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
/// Shown after resting on the tab.
|
||||
final String? tooltip;
|
||||
/// Small text under the tab (the version under Settings).
|
||||
final String? caption;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
@@ -382,7 +371,6 @@ class StripTab extends StatelessWidget {
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
this.tooltip,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -399,7 +387,6 @@ class StripTab extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Column(
|
||||
@@ -426,27 +413,6 @@ class StripTab extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (caption case final c?)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 2,
|
||||
height: 13,
|
||||
child: OverflowBox(
|
||||
maxWidth: 160,
|
||||
child: Text(
|
||||
c,
|
||||
key: const ValueKey('strip-version'),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
height: 1.2,
|
||||
color: scheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -522,18 +488,31 @@ class MembersCard extends StatelessWidget {
|
||||
final address = hosting
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
final delivery = r == null
|
||||
? null
|
||||
: deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
|
||||
final delivery = coord.status.value.delivery;
|
||||
final server = session.serverVersion.value;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
['In the server', ?address].join(' · '),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
['In the server', ?address].join(' · '),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (server.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: VersionText(
|
||||
key: const ValueKey('server-version'),
|
||||
label: 'Server',
|
||||
version: server,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (delivery != null) SourceLine(delivery: delivery),
|
||||
@@ -618,6 +597,7 @@ class _MemberTile extends StatelessWidget {
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(info.self ? '${m.name} (you)' : m.name),
|
||||
if (m.app.isNotEmpty) VersionText(key: ValueKey('version-${m.id}'), version: m.app),
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
@@ -651,6 +631,35 @@ class _MemberTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Starsync version, quiet ("1.3.0"), amber with a hint when it is not
|
||||
/// the one this app runs; [label] before it ("Server 1.3.0").
|
||||
class VersionText extends StatelessWidget {
|
||||
final String version;
|
||||
final String? label;
|
||||
const VersionText({super.key, required this.version, this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final differs = versionDiffers(version);
|
||||
final text = Text(
|
||||
[?label, version].join(' '),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: differs ? Colors.amber.shade300 : scheme.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontWeight: differs ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
);
|
||||
if (!differs) return text;
|
||||
return Tooltip(message: 'You run ${shortVersion()}', child: text);
|
||||
}
|
||||
}
|
||||
|
||||
/// [version] is not the one this app runs (a dev run matches anything:
|
||||
/// it has no version of its own).
|
||||
bool versionDiffers(String version, {String own = appVersion}) =>
|
||||
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -1,154 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../session/health.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_menu.dart' show AppGlass;
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, cannotPlay, waiting }
|
||||
export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, StatusLine, nameList;
|
||||
|
||||
/// The pill's button: stop reconnecting, retry (reconnect), reload the
|
||||
/// video (your player could not open it), or mark yourself ready (the
|
||||
/// whole pill).
|
||||
enum StatusAction { cancel, retry, reload, ready }
|
||||
|
||||
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||
/// and a loading [progress] (0..1).
|
||||
class StatusView {
|
||||
final StatusKind kind;
|
||||
final String text;
|
||||
final String? detail;
|
||||
final HealthSeverity? tone;
|
||||
final List<MemberView> who;
|
||||
final bool self;
|
||||
final double? progress;
|
||||
final StatusAction? action;
|
||||
/// The mini player's shorter line.
|
||||
final String short;
|
||||
const StatusView(
|
||||
this.kind,
|
||||
this.text, {
|
||||
this.detail,
|
||||
this.tone,
|
||||
this.who = const [],
|
||||
this.self = false,
|
||||
this.progress,
|
||||
this.action,
|
||||
String? short,
|
||||
}) : short = short ?? text;
|
||||
}
|
||||
|
||||
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
|
||||
String nameList(List<String> names) {
|
||||
if (names.length <= 1) return names.join();
|
||||
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
|
||||
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
|
||||
}
|
||||
|
||||
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
/// The player's status from the session's signals; null when there is
|
||||
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||
/// connecting has taken over 2 s. [playerError]: why your player cannot
|
||||
/// open the media (the coordinator's `playerError`).
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
bool unstable = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
String playerError = '',
|
||||
}) {
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (unstable) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Connection unstable…', tone: HealthSeverity.warn);
|
||||
}
|
||||
final away = self?.presence == Presence.away;
|
||||
if (playerError.isNotEmpty && state?.media != null && !away) {
|
||||
return StatusView(StatusKind.problem, "Can't play this: $playerError",
|
||||
tone: HealthSeverity.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||
tone: health.severity, action: StatusAction.retry);
|
||||
}
|
||||
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
|
||||
return const StatusView(StatusKind.connecting, 'Connecting…');
|
||||
}
|
||||
final r = state;
|
||||
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||
// Sitting out: the join-back card says it; nobody waits for you.
|
||||
if (away) return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
// Members whose player cannot open it: the server does not wait for
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && isPresent(m) && (self == null || m.id != self.id)) m,
|
||||
];
|
||||
// As the server holds playback: someone in the ready set short of the
|
||||
// minimum buffer.
|
||||
final need = r.bufferNeed;
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < need) {
|
||||
final you = self != null && m.id == self.id;
|
||||
final p = (m.buffered / need).clamp(0.0, 1.0);
|
||||
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final f = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusView(StatusKind.cannotPlay, "$who can't play this: ${f.error}",
|
||||
who: failed,
|
||||
short: "$who can't play this",
|
||||
detail: unready ? "Press Space when you're ready" : null,
|
||||
action: unready ? StatusAction.ready : null);
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||
who: [self], self: true, action: StatusAction.ready, detail: 'or click here', short: 'Press Space when ready');
|
||||
}
|
||||
// Members sitting out or gone are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (countsForReady(m)) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
];
|
||||
if (others.isEmpty) return null;
|
||||
final ready = active.where((m) => m.ready).length;
|
||||
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
||||
who: others, detail: '$ready/${active.length} ready');
|
||||
}
|
||||
|
||||
/// [playerStatusFor] from the session (tracked: call inside a builder).
|
||||
StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
final c = SessionState.instance.coordinator;
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
unstable: c.unstable.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
playerError: c.playerError.value,
|
||||
);
|
||||
/// The pill's line from the session's [SessionCoordinator.status]
|
||||
/// (tracked: call inside a builder); "Connecting…" only once it has taken
|
||||
/// [PlayerStatus.connectGrace] ([slowConnect]).
|
||||
StatusLine? playerStatusNow({bool slowConnect = false}) {
|
||||
final line = SessionState.instance.coordinator.status.value.line;
|
||||
if (line?.kind == StatusKind.connecting && !slowConnect) return null;
|
||||
return line;
|
||||
}
|
||||
|
||||
/// The player's one status pill (bottom centre): frosted dark glass with
|
||||
@@ -167,7 +35,7 @@ class PlayerStatus extends StatefulWidget {
|
||||
class _PlayerStatusState extends State<PlayerStatus> {
|
||||
Timer? _connectTimer;
|
||||
bool _slow = false;
|
||||
StatusView? _last;
|
||||
StatusLine? _last;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -176,8 +44,8 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
}
|
||||
|
||||
// "Connecting…" only once it takes a while.
|
||||
void _trackConnecting(HealthStatus h) {
|
||||
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
|
||||
void _trackConnecting(StatusLine? line) {
|
||||
final connecting = line?.kind == StatusKind.connecting;
|
||||
if (!connecting) {
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = null;
|
||||
@@ -206,7 +74,7 @@ class _PlayerStatusState extends State<PlayerStatus> {
|
||||
Widget build(BuildContext context) {
|
||||
return PartyWatch(builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
_trackConnecting(coord.health.value);
|
||||
_trackConnecting(coord.status.value.line);
|
||||
final view = playerStatusNow(slowConnect: _slow);
|
||||
if (view != null) _last = view;
|
||||
final shown = _last;
|
||||
@@ -262,7 +130,7 @@ class PlayerGlass extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
final StatusView view;
|
||||
final StatusLine view;
|
||||
final SessionCoordinator coord;
|
||||
final ValueChanged<StatusAction> onAction;
|
||||
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||
@@ -273,12 +141,13 @@ class _StatusLine extends StatelessWidget {
|
||||
final v = view;
|
||||
switch (v.kind) {
|
||||
case StatusKind.problem:
|
||||
final error = v.tone == HealthSeverity.error;
|
||||
final error = v.level == StatusLevel.error;
|
||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||
size: 20, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||
case StatusKind.reconnecting:
|
||||
return const _Spinner(color: Colors.amber);
|
||||
case StatusKind.connecting:
|
||||
case StatusKind.fetching:
|
||||
return const _Spinner(color: Colors.white70);
|
||||
case StatusKind.finished:
|
||||
return null;
|
||||
|
||||
@@ -63,6 +63,9 @@ class SessionPlayer extends StatefulWidget {
|
||||
/// Window buttons in the fullscreen top bar (off in tests: they need
|
||||
/// window_manager).
|
||||
final bool windowControls;
|
||||
/// Follows whether the full player's controls are up (the shell lifts
|
||||
/// its toasts above them); the player keeps its own when null.
|
||||
final ValueNotifier<bool>? controlsShown;
|
||||
const SessionPlayer({
|
||||
super.key,
|
||||
required this.mode,
|
||||
@@ -79,6 +82,7 @@ class SessionPlayer extends StatefulWidget {
|
||||
this.unread = 0,
|
||||
this.videoBuilder,
|
||||
this.windowControls = true,
|
||||
this.controlsShown,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -96,7 +100,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up (their frame reports it).
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
final _ownShown = ValueNotifier(true);
|
||||
ValueNotifier<bool> get _controlsShown => widget.controlsShown ?? _ownShown;
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
final _hold = ControlsHold();
|
||||
@@ -155,7 +160,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_stopShaderWatch();
|
||||
_stopPopoverWatch();
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
_ownShown.dispose();
|
||||
_hold.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../jellyfin/accounts_panel.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
@@ -228,6 +229,19 @@ class SettingsTab extends StatelessWidget {
|
||||
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 14),
|
||||
..._page(context),
|
||||
// The version, quietly (also the Settings tab's
|
||||
// tooltip and About).
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SelectableText(
|
||||
'$appName ${displayVersion()}',
|
||||
key: const ValueKey('settings-version'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -730,8 +744,9 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
||||
/// a sound.
|
||||
/// What pops up (chat pop-ups: [AppSettings.chatPopups]), what reaches
|
||||
/// you while you sit out or the window is in the background
|
||||
/// ([AppSettings.notifyPeople]), and what makes a sound.
|
||||
class NotificationsInfo extends StatelessWidget {
|
||||
const NotificationsInfo({super.key});
|
||||
|
||||
@@ -753,8 +768,9 @@ class NotificationsInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves and video changes',
|
||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||
line('Joins, leaves, lost connections and video changes',
|
||||
"Pop up bottom left for 5 s with the person's picture, above the player's controls"),
|
||||
line('Added to Up next', 'With the title, its picture and who added it'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
@@ -781,7 +797,30 @@ class NotificationsInfo extends StatelessWidget {
|
||||
),
|
||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||
line('App notices', 'Shader changes, auto sit-out setting you away'),
|
||||
line('Watching alone', 'No pop-ups or sounds about the party: there is nobody else'),
|
||||
line('App notices', 'Shader changes, copies, errors, auto sit-out setting you away'),
|
||||
],
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Sitting out and in the background',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('What still reaches you',
|
||||
'While you sit out, only chat, nudges, "starting now" and Up next pop up. With the window '
|
||||
'in the background or minimised they also go out as system notifications; a click brings '
|
||||
'the window up (a chat one opens the chat)'),
|
||||
SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('notify-people'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Joins and leaves too'),
|
||||
subtitle: Text('Who joins, leaves, loses connection or comes back', style: muted),
|
||||
value: AppSettings.instance.notifyPeople.value,
|
||||
onChanged: AppSettings.instance.setNotifyPeople,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,29 +5,29 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/session_coordinator.dart' show ToastItem;
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// One toast on screen: who (null = the app itself), what, and whether it
|
||||
/// is on its way out.
|
||||
/// One toast on screen: who (null = the app itself), what, a picture
|
||||
/// (an Up next item's), and whether it is on its way out.
|
||||
class ShellToast {
|
||||
final int id;
|
||||
final AvatarInfo? who;
|
||||
final IconData? icon;
|
||||
final String text;
|
||||
final bool chat;
|
||||
final String thumb;
|
||||
bool leaving = false;
|
||||
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
|
||||
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Splits a Notifier 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: ')) {
|
||||
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
|
||||
if (t.chat && t.text.startsWith('$n: ')) {
|
||||
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
||||
}
|
||||
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
||||
@@ -35,24 +35,30 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||
: rest.startsWith('lost connection')
|
||||
? Icons.wifi_off_rounded
|
||||
: rest.startsWith('is back')
|
||||
? Icons.wifi_rounded
|
||||
: rest.startsWith('sat you out')
|
||||
? Icons.bedtime
|
||||
: rest.startsWith('nudged')
|
||||
? Icons.notifications_active_outlined
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: rest.startsWith('added')
|
||||
? Icons.playlist_add_rounded
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb);
|
||||
}
|
||||
|
||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||
/// through. Chat toasts follow [AppSettings.chatPopups].
|
||||
/// The Notifier's toasts as cards with avatars, bottom-left: each slides
|
||||
/// in, stays while the Notifier keeps it (it drops it after its time),
|
||||
/// then slides out and collapses its gap. A click on a chat toast opens
|
||||
/// the chat ([onOpenChat]); the rest let clicks through. What pops up at
|
||||
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
final VoidCallback? onOpenChat;
|
||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5), this.onOpenChat});
|
||||
const PartyToasts({super.key, this.onOpenChat});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
@@ -72,33 +78,40 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
_seen = {for (final t in coord.toasts.peek()) t.id};
|
||||
_stop = effect(() {
|
||||
final items = coord.toasts.value;
|
||||
final ids = {for (final t in items) t.id};
|
||||
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
||||
_seen = {for (final t in items) t.id};
|
||||
if (fresh.isEmpty) return;
|
||||
final gone = _seen.difference(ids);
|
||||
_seen = ids;
|
||||
if (fresh.isEmpty && gone.isEmpty) return;
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
final playing = coord.serverState.value?.transport == 'playing';
|
||||
final popups = AppSettings.instance.chatPopups.value;
|
||||
for (final t in fresh) {
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord, t.actorId));
|
||||
// Chat per Settings → Notifications (it is in the chat either way).
|
||||
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||
_add(toast);
|
||||
_add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId)));
|
||||
}
|
||||
if (gone.isNotEmpty) _leave(gone);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _add(ShellToast t) {
|
||||
// Not from inside the effect's build-time notification.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) setState(() => _shown.add(t));
|
||||
});
|
||||
}
|
||||
|
||||
void _leave(Set<int> ids) {
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted) return;
|
||||
setState(() => _shown.add(t));
|
||||
_timers.add(Timer(widget.life, () {
|
||||
if (mounted) setState(() => t.leaving = true);
|
||||
}));
|
||||
_timers.add(Timer(widget.life + easeTime, () {
|
||||
if (mounted) setState(() => _shown.remove(t));
|
||||
final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
|
||||
if (leaving.isEmpty) return;
|
||||
setState(() {
|
||||
for (final t in leaving) {
|
||||
t.leaving = true;
|
||||
}
|
||||
});
|
||||
_timers.add(Timer(easeTime, () {
|
||||
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -114,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
|
||||
void _open(ShellToast t) {
|
||||
widget.onOpenChat?.call();
|
||||
setState(() => t.leaving = true);
|
||||
SessionState.instance.notifier.dismiss(t.id);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -206,6 +219,8 @@ class ToastCard extends StatelessWidget {
|
||||
children: [
|
||||
if (who != null)
|
||||
StatusAvatar(info: who, size: 38, badges: false)
|
||||
else if (toast.thumb.isNotEmpty)
|
||||
_Thumb(hash: toast.thumb)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 19,
|
||||
@@ -237,6 +252,8 @@ class ToastCard extends StatelessWidget {
|
||||
],
|
||||
Text(
|
||||
toast.text,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: toast.chat ? 15 : 14,
|
||||
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||
@@ -245,6 +262,10 @@ class ToastCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (who != null && toast.thumb.isNotEmpty) ...[
|
||||
const SizedBox(width: 12),
|
||||
_Thumb(hash: toast.thumb),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -252,3 +273,31 @@ class ToastCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An Up next item's picture (16:9, from the queue's picture cache);
|
||||
/// a blank until it is fetched.
|
||||
class _Thumb extends StatelessWidget {
|
||||
final String hash;
|
||||
const _Thumb({required this.hash});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbs = SessionState.instance.coordinator.queue.thumbs;
|
||||
return ClipRRect(
|
||||
key: const ValueKey('toast-thumb'),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 68,
|
||||
height: 38,
|
||||
child: ListenableBuilder(
|
||||
listenable: thumbs,
|
||||
builder: (context, _) {
|
||||
final bytes = thumbs.bytesFor(hash);
|
||||
if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
||||
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user