Restructure step 2: Status as one computed level, the Notifier's real policy, system notifications
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m11s
ci / updater-windows (push) Failing after 6m16s

- 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

+18 -13
View File
@@ -16,7 +16,6 @@ import '../platform/ports/ports.dart';
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
import '../session/notifier.dart';
import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity;
@@ -163,7 +162,6 @@ class SessionState {
final route = signal(AppRoute.home);
final recents = signal<List<RecentServer>>(const []);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
/// Owns the app's audio device ([SynthSfx.dispose] on close).
final SynthSfx sfx = SynthSfx(
volume: () => AppSettings.instance.soundVolume.peek(),
@@ -173,8 +171,15 @@ class SessionState {
);
/// The platform (window, links, folders, yt-dlp, notifications).
final Ports ports = desktopPorts();
/// Every toast, sound and activity-log line goes through it.
late final Notifier notifier = Notifier(sfx: sfx, attention: ports.shell.requestAttention);
/// Every toast, sound, system notification and activity-log line goes
/// through it.
late final Notifier notifier = Notifier(
sfx: sfx,
attention: ports.shell.requestAttention,
system: ports.notifications,
focusWindow: ports.shell.focus,
prefs: () => AppSettings.instance.notifyPrefs,
);
/// The link to the server; lost/back go to the notifier.
/// The link to the server, with this install's stored identity
/// (`hello.client` / `seq`); lost/back go to the notifier.
@@ -192,6 +197,9 @@ class SessionState {
/// The joined server's own name (`/health`), '' until known or when it
/// only has the default name.
final serverLabel = signal('');
/// The server's Starsync version: a hosted (or solo) server is this
/// app's own; a joined one's comes from its `/health`; '' until known.
final serverVersion = signal('');
/// A solo session is becoming a hosted one: the player has nothing open
/// until the media is up again on the new server.
final switching = signal(false);
@@ -367,8 +375,6 @@ class SessionState {
final op = ++_op;
if (form != null) _rememberForm(form);
try {
status.value =
const HealthStatus(HealthSeverity.info, 'starting server…');
await stopServer();
if (op != _op) throw const ConnectCancelled();
final hostKey = newHostKey();
@@ -403,8 +409,6 @@ class SessionState {
);
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
status.value = HealthStatus(
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
rethrow;
}
if (op != _op) {
@@ -414,8 +418,7 @@ class SessionState {
_server = server;
coordinator.hostFile = (path) async =>
identical(_server, server) ? server.setFile(path) : null;
status.value = HealthStatus(
HealthSeverity.ok, soloSession ? 'watching solo' : 'hosting on port ${server.port}');
serverVersion.value = appVersion;
try {
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
@@ -452,7 +455,6 @@ class SessionState {
_setupCrash = cause;
return;
}
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
serverCrash.value = message;
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
}
@@ -465,9 +467,12 @@ class SessionState {
if (op != _op) throw const ConnectCancelled();
route.value = AppRoute.session;
serverLabel.value = '';
serverVersion.value = '';
unawaited(probeHealth(params.host, '${params.port}', defaultHealthGet).then((h) {
if (op != _op || !h.online) return;
// 'server' is the dedicated server's default --name: not a name.
if (op == _op && h.online && h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
if (h.name.isNotEmpty && h.name != 'server') serverLabel.value = h.name;
serverVersion.value = h.version;
}).catchError((Object _) {}));
} finally {
await _saveFormQuietly();
@@ -524,7 +529,7 @@ class SessionState {
solo.value = false;
coordinator.solo = false;
serverLabel.value = '';
status.value = const HealthStatus(HealthSeverity.info, 'idle');
serverVersion.value = '';
route.value = AppRoute.home;
}
}
+17 -17
View File
@@ -6,8 +6,11 @@ import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart';
import '../session/notice_prefs.dart';
import 'settings_schema.dart';
export '../session/notice_prefs.dart' show ChatPopups, NotifyPrefs;
// App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded]
// finishes; setters write through.
@@ -39,6 +42,7 @@ const _keySoundPresetFor = 'soundPresetFor';
const _keyUnreadyOnSwap = 'unreadyOnSwap';
const keyAutoSitOut = 'autoSitOut';
const _keyChatPopups = 'chatPopups';
const _keyNotifyPeople = 'notifyPeople';
const _keyShareDiagnostics = 'shareDiagnostics';
enum HwdecMode { auto, off, custom }
@@ -49,23 +53,6 @@ enum HwdecMode { auto, off, custom }
/// surface there ("Failed to create EGL surface"). Elsewhere 'auto'.
String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
/// When chat messages pop up as toasts (they always reach the chat).
enum ChatPopups {
all('All'),
paused('Only when paused'),
off('Off');
final String label;
const ChatPopups(this.label);
/// Whether a chat message pops up now ([playing]: the video plays).
bool shows({required bool playing}) => switch (this) {
ChatPopups.all => true,
ChatPopups.paused => !playing,
ChatPopups.off => false,
};
}
class AppSettings {
static final AppSettings instance = AppSettings();
@@ -100,6 +87,12 @@ class AppSettings {
final shareDiagnostics = signal(true);
/// Chat message toasts (default: all). Applies live.
final chatPopups = signal(ChatPopups.all);
/// Joins and leaves also notify while sitting out or with the window in
/// the background (default off). Applies live.
final notifyPeople = signal(false);
/// The notification choices as the Notifier reads them.
NotifyPrefs get notifyPrefs => NotifyPrefs(chat: chatPopups.peek(), people: notifyPeople.peek());
/// App sounds (`sfx/`): 0..1, 0 = silent.
final soundVolume = signal(1.0);
/// `SfxKind` names switched off one by one.
@@ -174,6 +167,8 @@ class AppSettings {
if (share is bool) shareDiagnostics.value = share;
final popups = box.get(_keyChatPopups);
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
final people = box.get(_keyNotifyPeople);
if (people is bool) notifyPeople.value = people;
final vol = box.get(_keySoundVolume);
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
final off = box.get(_keySoundsOff);
@@ -278,6 +273,11 @@ class AppSettings {
_put(_keyChatPopups, mode.name);
}
void setNotifyPeople(bool on) {
notifyPeople.value = on;
_put(_keyNotifyPeople, on);
}
void setSoundVolume(double v) {
soundVolume.value = v.clamp(0, 1);
_put(_keySoundVolume, soundVolume.value);