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:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:11:08 +03:00
1 parent 130c934b9b
commit 629be0d9ed
41 files changed
+2114 -741

No files matched your search

+50 -5
View File
@@ -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,
),
),
),
+5
View File
@@ -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),
+5 -8
View File
@@ -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
+55 -46
View File
@@ -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 {
+15 -146
View File
@@ -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();
+44 -5
View File
@@ -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,
),
),
],
),
),
+87 -38
View File
@@ -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);
},
),
),
);
}
}