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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user