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
@@ -52,8 +52,12 @@ Future<void> main(List<String> args) async {
|
||||
final ports = SessionState.instance.ports;
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
||||
// Focus, minimise and restore feed auto sit-out.
|
||||
ports.shell.watchActivity(SessionState.instance.coordinator.windowActivity);
|
||||
// Focus, minimise and restore feed auto sit-out and the Notifier (in
|
||||
// the background, notices go out as system notifications).
|
||||
ports.shell.watchActivity((a) {
|
||||
SessionState.instance.coordinator.windowActivity(a);
|
||||
SessionState.instance.notifier.window = a;
|
||||
});
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../desktop_entry.dart' show appId;
|
||||
import '../ports/system_notifications.dart';
|
||||
|
||||
/// [SystemNotifications] on the desktop, through flutter_local_notifications'
|
||||
/// platform plugins (used directly, not through its facade, so the platform
|
||||
/// is picked by the OS and not by Flutter's target platform):
|
||||
/// - Linux: `org.freedesktop.Notifications` over D-Bus (pure Dart), with the
|
||||
/// app's icon (the bundled PNG, else the launcher entry's icon by name);
|
||||
/// a click is the notification's default action.
|
||||
/// - Windows: toast notifications (WinRT), the app's name and icon, a click
|
||||
/// activates the app while it runs (an unpackaged app has no history).
|
||||
/// Silent: the app plays its own sound for the same notice. Anything that
|
||||
/// fails (no notification daemon, no session bus) is logged and dropped.
|
||||
class DesktopNotifications implements SystemNotifications {
|
||||
DesktopNotifications({this.appName = 'Starsync'});
|
||||
|
||||
final String appName;
|
||||
void Function(String payload)? _onClick;
|
||||
Future<bool>? _ready;
|
||||
LinuxFlutterLocalNotificationsPlugin? _linux;
|
||||
FlutterLocalNotificationsWindows? _windows;
|
||||
// Tag -> id, so a notification with the same tag replaces the last one.
|
||||
final _ids = <String, int>{};
|
||||
int _next = 1;
|
||||
|
||||
@override
|
||||
set onClick(void Function(String payload)? handler) => _onClick = handler;
|
||||
|
||||
void _clicked(NotificationResponse r) => _onClick?.call(r.payload ?? '');
|
||||
|
||||
/// The bundled 256 px icon next to the executable (a release or a
|
||||
/// `flutter run` build), else the icon the launcher entry installed.
|
||||
static LinuxNotificationIcon _linuxIcon() {
|
||||
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||
final png = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||
return png.existsSync() ? FilePathLinuxIcon(png.path) : ThemeLinuxIcon(appId);
|
||||
}
|
||||
|
||||
Future<bool> _init() => _ready ??= () async {
|
||||
try {
|
||||
if (Platform.isLinux) {
|
||||
final p = _linux = LinuxFlutterLocalNotificationsPlugin();
|
||||
return await p.initialize(
|
||||
settings: LinuxInitializationSettings(defaultActionName: 'Open', defaultIcon: _linuxIcon()),
|
||||
onDidReceiveNotificationResponse: _clicked,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
final exe = File(Platform.resolvedExecutable).parent.path;
|
||||
final icon = File('$exe/data/flutter_assets/assets/icon/icon_256.png');
|
||||
final p = _windows = FlutterLocalNotificationsWindows();
|
||||
return await p.initialize(
|
||||
settings: WindowsInitializationSettings(
|
||||
appName: appName,
|
||||
appUserModelId: 'Foxyz.Starsync',
|
||||
guid: '6c0f4b4e-3e8a-4b8e-9d0c-5a7f2b1e9c31',
|
||||
iconPath: icon.existsSync() ? icon.path : null,
|
||||
),
|
||||
onDidReceiveNotificationResponse: _clicked,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('system notifications unavailable: $e');
|
||||
}
|
||||
return false;
|
||||
}();
|
||||
|
||||
@override
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {
|
||||
if (!await _init()) return;
|
||||
final id = tag.isEmpty ? _next++ : _ids[tag] ??= _next++;
|
||||
try {
|
||||
if (_linux case final p?) {
|
||||
await p.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: payload,
|
||||
notificationDetails: const LinuxNotificationDetails(suppressSound: true),
|
||||
);
|
||||
} else if (_windows case final p?) {
|
||||
await p.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: payload,
|
||||
notificationDetails: WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('system notification failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../../playback/yt_dlp_app.dart';
|
||||
import '../ports/ports.dart';
|
||||
import 'desktop_folders.dart';
|
||||
import 'desktop_notifications.dart';
|
||||
import 'desktop_shell.dart';
|
||||
import 'desktop_urls.dart';
|
||||
|
||||
@@ -12,6 +13,6 @@ Ports desktopPorts() {
|
||||
urls: urls,
|
||||
folders: const DesktopFolders(urls),
|
||||
links: AppYtDlp.instance,
|
||||
notifications: const NoSystemNotifications(),
|
||||
notifications: DesktopNotifications(),
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,17 @@ class DesktopShell implements ShellPort {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> focus() async {
|
||||
try {
|
||||
if (await windowManager.isMinimized()) await windowManager.restore();
|
||||
if (!await windowManager.isVisible()) await windowManager.show();
|
||||
await windowManager.focus();
|
||||
} catch (e) {
|
||||
debugPrint('window focus failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestAttention() => requestWindowAttention();
|
||||
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Where the app window stands, for auto sit-out
|
||||
/// ([ShellPort.watchActivity]).
|
||||
enum WindowActivity {
|
||||
/// The window has the keyboard focus.
|
||||
focused,
|
||||
import 'window_activity.dart';
|
||||
|
||||
/// Shown, but another window has the focus.
|
||||
unfocused,
|
||||
|
||||
/// Minimised to the taskbar.
|
||||
minimised,
|
||||
|
||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||
hidden,
|
||||
}
|
||||
export 'window_activity.dart';
|
||||
|
||||
/// What the clipboard holds for a paste: its files, else its text.
|
||||
typedef Paste = ({List<String> files, String? text});
|
||||
@@ -54,6 +42,10 @@ abstract interface class ShellPort {
|
||||
Future<void> hide();
|
||||
Future<void> quit();
|
||||
|
||||
/// Brings the window up and gives it the focus (restored when
|
||||
/// minimised, shown when hidden): a click on a system notification.
|
||||
Future<void> focus();
|
||||
|
||||
/// Points the desktop at the window when it is not focused (taskbar
|
||||
/// flash, urgency hint); nothing when it is.
|
||||
Future<void> requestAttention();
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/// Notifications outside the window (the desktop's notification center,
|
||||
/// Android's shade). The policy that uses them comes with the Notifier.
|
||||
/// Android's shade). The Notifier decides what goes out here (see
|
||||
/// `noticeFor`); the port only shows them and reports clicks.
|
||||
abstract interface class SystemNotifications {
|
||||
/// Shows [title] / [body]; replaces the one with the same [tag].
|
||||
Future<void> show({required String title, required String body, String tag = ''});
|
||||
/// Shows [title] / [body] with the app's name and icon; one with the
|
||||
/// same [tag] replaces it. [payload] comes back to [onClick].
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''});
|
||||
|
||||
/// Called with a notification's payload when the user clicks it (set
|
||||
/// once, by the Notifier).
|
||||
set onClick(void Function(String payload)? handler);
|
||||
}
|
||||
|
||||
/// Shows nothing (desktop today).
|
||||
/// Shows nothing (tests, platforms without one).
|
||||
class NoSystemNotifications implements SystemNotifications {
|
||||
const NoSystemNotifications();
|
||||
|
||||
@override
|
||||
Future<void> show({required String title, required String body, String tag = ''}) async {}
|
||||
Future<void> show({required String title, required String body, String tag = '', String payload = ''}) async {}
|
||||
|
||||
@override
|
||||
set onClick(void Function(String payload)? handler) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// Where the app window stands ([ShellPort.watchActivity]): auto sit-out
|
||||
/// and the Notifier (system notifications while it is not focused) read
|
||||
/// it. Pure Dart.
|
||||
enum WindowActivity {
|
||||
/// The window has the keyboard focus.
|
||||
focused,
|
||||
|
||||
/// Shown, but another window has the focus.
|
||||
unfocused,
|
||||
|
||||
/// Minimised to the taskbar.
|
||||
minimised,
|
||||
|
||||
/// Hidden (closing, or a platform that hides instead of minimising).
|
||||
hidden;
|
||||
|
||||
/// Not in front of the user: notices go out as system notifications.
|
||||
bool get background => this != focused;
|
||||
}
|
||||
@@ -16,12 +16,15 @@ class ServerHealth {
|
||||
final String name;
|
||||
final int? proto;
|
||||
final int viewers;
|
||||
/// Its Starsync version (`version`), '' from a server too old to say.
|
||||
final String version;
|
||||
const ServerHealth.offline()
|
||||
: online = false,
|
||||
name = '',
|
||||
proto = null,
|
||||
viewers = 0;
|
||||
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
|
||||
viewers = 0,
|
||||
version = '';
|
||||
const ServerHealth.online({required this.name, this.proto, this.viewers = 0, this.version = ''})
|
||||
: online = true;
|
||||
|
||||
/// True when the server speaks another protocol than this build.
|
||||
@@ -33,11 +36,12 @@ class ServerHealth {
|
||||
try {
|
||||
final j = jsonDecode(body);
|
||||
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
||||
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
|
||||
final name = j['name'], proto = j['proto'], viewers = j['viewers'], version = j['version'];
|
||||
return ServerHealth.online(
|
||||
name: name is String ? name.trim() : '',
|
||||
proto: proto is num ? proto.toInt() : null,
|
||||
viewers: viewers is num ? viewers.toInt() : 0,
|
||||
version: version is String ? version.trim() : '',
|
||||
);
|
||||
} catch (_) {
|
||||
return const ServerHealth.offline();
|
||||
|
||||
@@ -15,65 +15,71 @@ sealed class AppEvent {
|
||||
/// in the activity log.
|
||||
sealed class FeedEvent extends AppEvent {
|
||||
final FeedMessage feed;
|
||||
const FeedEvent(this.feed);
|
||||
/// This member is the actor (its own chat, ready, queue add...).
|
||||
final bool own;
|
||||
const FeedEvent(this.feed, {this.own = false});
|
||||
|
||||
String get actor => feed.actor;
|
||||
|
||||
/// [feed] as its typed event. [selfId]: this member's id (people are
|
||||
/// matched by `actorId` / `targetId`, never by name); [readyCount]:
|
||||
/// members ready in the latest state (the pitch of a `ready` sound).
|
||||
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0}) {
|
||||
/// members ready in the latest state (the pitch of a `ready` sound);
|
||||
/// [queueThumb]: the picture hash of the Up next item a `queue-add`
|
||||
/// added (from the state that came with it), '' when none.
|
||||
factory FeedEvent.of(FeedMessage f, {String selfId = '', int readyCount = 0, String queueThumb = ''}) {
|
||||
bool isSelf(String id) => selfId.isNotEmpty && id == selfId;
|
||||
final own = isSelf(f.actorId);
|
||||
return switch (f.action) {
|
||||
'join' => MemberJoined(f),
|
||||
'leave' => MemberLeft(f),
|
||||
'gone' => MemberGone(f),
|
||||
'return' => MemberReturned(f),
|
||||
'join' => MemberJoined(f, own: own),
|
||||
'leave' => MemberLeft(f, own: own),
|
||||
'gone' => MemberGone(f, own: own),
|
||||
'return' => MemberReturned(f, own: own),
|
||||
'away' => MemberSatOut(f,
|
||||
own: own,
|
||||
atYou: isSelf(f.targetId) && !isSelf(f.actorId),
|
||||
autoSelf: isSelf(f.actorId) && f.targetId.isEmpty && f.reason.isNotEmpty),
|
||||
'in' => MemberBack(f),
|
||||
'ready' => ReadyChanged(f, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, ready: false),
|
||||
'force-ready' => ForcedReady(f, ready: true),
|
||||
'force-unready' => ForcedReady(f, ready: false),
|
||||
'force-play' => ForcedTransport(f, play: true),
|
||||
'force-hold' => ForcedTransport(f, play: false),
|
||||
'swap' => MediaSwapped(f),
|
||||
'seek' => Seeked(f),
|
||||
'pause' => Paused(f),
|
||||
'resume' => Resumed(f),
|
||||
'finished' => Finished(f),
|
||||
'chat' => ChatPosted(f, own: isSelf(f.actorId)),
|
||||
'rename' => MemberRenamed(f),
|
||||
'avatar' => AvatarChanged(f),
|
||||
'queue-add' => QueueAdded(f),
|
||||
'queue-remove' => QueueRemoved(f),
|
||||
'queue-skip' => QueueSkipped(f),
|
||||
'fetch-fail' => FetchFailed(f),
|
||||
_ => OtherFeed(f),
|
||||
'in' => MemberBack(f, own: own),
|
||||
'ready' => ReadyChanged(f, own: own, ready: true, readyIndex: readyCount),
|
||||
'unready' => ReadyChanged(f, own: own, ready: false),
|
||||
'force-ready' => ForcedReady(f, own: own, ready: true),
|
||||
'force-unready' => ForcedReady(f, own: own, ready: false),
|
||||
'force-play' => ForcedTransport(f, own: own, play: true),
|
||||
'force-hold' => ForcedTransport(f, own: own, play: false),
|
||||
'swap' => MediaSwapped(f, own: own),
|
||||
'seek' => Seeked(f, own: own),
|
||||
'pause' => Paused(f, own: own),
|
||||
'resume' => Resumed(f, own: own),
|
||||
'finished' => Finished(f, own: own),
|
||||
'chat' => ChatPosted(f, own: own),
|
||||
'rename' => MemberRenamed(f, own: own),
|
||||
'avatar' => AvatarChanged(f, own: own),
|
||||
'queue-add' => QueueAdded(f, own: own, thumb: queueThumb),
|
||||
'queue-remove' => QueueRemoved(f, own: own),
|
||||
'queue-skip' => QueueSkipped(f, own: own),
|
||||
'fetch-fail' => FetchFailed(f, own: own),
|
||||
_ => OtherFeed(f, own: own),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MemberJoined extends FeedEvent {
|
||||
const MemberJoined(super.feed);
|
||||
const MemberJoined(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Left for good (`reason` `bye`: the app closed or Leave; `grace`: gone
|
||||
/// too long).
|
||||
class MemberLeft extends FeedEvent {
|
||||
const MemberLeft(super.feed);
|
||||
const MemberLeft(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Lost signal: gone for a while, kept in its grace period.
|
||||
class MemberGone extends FeedEvent {
|
||||
const MemberGone(super.feed);
|
||||
const MemberGone(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Back within its grace after a [MemberGone].
|
||||
class MemberReturned extends FeedEvent {
|
||||
const MemberReturned(super.feed);
|
||||
const MemberReturned(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Someone sat out (`away`: the actor itself, or `target` sat out by the
|
||||
@@ -83,87 +89,89 @@ class MemberReturned extends FeedEvent {
|
||||
class MemberSatOut extends FeedEvent {
|
||||
final bool atYou;
|
||||
final bool autoSelf;
|
||||
const MemberSatOut(super.feed, {this.atYou = false, this.autoSelf = false});
|
||||
const MemberSatOut(super.feed, {super.own, this.atYou = false, this.autoSelf = false});
|
||||
}
|
||||
|
||||
/// Back from sitting out (`in`).
|
||||
class MemberBack extends FeedEvent {
|
||||
const MemberBack(super.feed);
|
||||
const MemberBack(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class ReadyChanged extends FeedEvent {
|
||||
final bool ready;
|
||||
/// Members ready when it came (pitch of the ready sound).
|
||||
final int readyIndex;
|
||||
const ReadyChanged(super.feed, {required this.ready, this.readyIndex = 0});
|
||||
const ReadyChanged(super.feed, {super.own, required this.ready, this.readyIndex = 0});
|
||||
}
|
||||
|
||||
class ForcedReady extends FeedEvent {
|
||||
final bool ready;
|
||||
const ForcedReady(super.feed, {required this.ready});
|
||||
const ForcedReady(super.feed, {super.own, required this.ready});
|
||||
}
|
||||
|
||||
/// Force play / hold.
|
||||
class ForcedTransport extends FeedEvent {
|
||||
final bool play;
|
||||
const ForcedTransport(super.feed, {required this.play});
|
||||
const ForcedTransport(super.feed, {super.own, required this.play});
|
||||
}
|
||||
|
||||
class MediaSwapped extends FeedEvent {
|
||||
const MediaSwapped(super.feed);
|
||||
const MediaSwapped(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class Seeked extends FeedEvent {
|
||||
const Seeked(super.feed);
|
||||
const Seeked(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class Paused extends FeedEvent {
|
||||
const Paused(super.feed);
|
||||
const Paused(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class Resumed extends FeedEvent {
|
||||
const Resumed(super.feed);
|
||||
const Resumed(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class Finished extends FeedEvent {
|
||||
const Finished(super.feed);
|
||||
const Finished(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// A chat message (`reason` carries the text); [own]: this member sent it.
|
||||
class ChatPosted extends FeedEvent {
|
||||
final bool own;
|
||||
const ChatPosted(super.feed, {this.own = false});
|
||||
const ChatPosted(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class MemberRenamed extends FeedEvent {
|
||||
const MemberRenamed(super.feed);
|
||||
const MemberRenamed(super.feed, {super.own});
|
||||
}
|
||||
|
||||
class AvatarChanged extends FeedEvent {
|
||||
const AvatarChanged(super.feed);
|
||||
const AvatarChanged(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Someone added an item to Up next (`target` its title); [thumb]: the
|
||||
/// item's picture hash ('' when none).
|
||||
class QueueAdded extends FeedEvent {
|
||||
const QueueAdded(super.feed);
|
||||
final String thumb;
|
||||
const QueueAdded(super.feed, {super.own, this.thumb = ''});
|
||||
}
|
||||
|
||||
class QueueRemoved extends FeedEvent {
|
||||
const QueueRemoved(super.feed);
|
||||
const QueueRemoved(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// Up next skipped an item (`target` its title, `reason` why).
|
||||
class QueueSkipped extends FeedEvent {
|
||||
const QueueSkipped(super.feed);
|
||||
const QueueSkipped(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// The server could not fetch the current media.
|
||||
class FetchFailed extends FeedEvent {
|
||||
const FetchFailed(super.feed);
|
||||
const FetchFailed(super.feed, {super.own});
|
||||
}
|
||||
|
||||
/// An action this app does not know (a newer server).
|
||||
class OtherFeed extends FeedEvent {
|
||||
const OtherFeed(super.feed);
|
||||
const OtherFeed(super.feed, {super.own});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- session
|
||||
@@ -209,6 +217,38 @@ class ConnectionBack extends AppEvent {
|
||||
const ConnectionBack();
|
||||
}
|
||||
|
||||
/// The server refused something this app sent (`err`: Up next is full, a
|
||||
/// forbidden action...).
|
||||
class ServerRefused extends AppEvent {
|
||||
final String text;
|
||||
const ServerRefused(this.text);
|
||||
}
|
||||
|
||||
/// The member passing the current video on has been gone a few seconds
|
||||
/// ([left]: it left the server); viewers play what the server has.
|
||||
class RelaySourceDropped extends AppEvent {
|
||||
final String name;
|
||||
final bool left;
|
||||
const RelaySourceDropped(this.name, {this.left = false});
|
||||
}
|
||||
|
||||
/// The current media started playing for the first time ("starting
|
||||
/// now"; [title]: what plays, '' when unknown).
|
||||
class PlaybackStarted extends AppEvent {
|
||||
final String title;
|
||||
const PlaybackStarted(this.title);
|
||||
}
|
||||
|
||||
/// A reconnect found the server restarted (another `welcome.instance`).
|
||||
class ServerRestarted extends AppEvent {
|
||||
const ServerRestarted();
|
||||
}
|
||||
|
||||
/// Another window of this install took the member over (`replaced`).
|
||||
class Replaced extends AppEvent {
|
||||
const Replaced();
|
||||
}
|
||||
|
||||
/// Jellyfin progress reports fail (once per media).
|
||||
class JellyfinProgressFailed extends AppEvent {
|
||||
final bool signInExpired;
|
||||
|
||||
@@ -6,6 +6,12 @@ import 'party.dart';
|
||||
// Who fetches the current media and how it spreads, from the state alone:
|
||||
// the members card's source line and each member's route and buffer.
|
||||
|
||||
/// `media.relay` of a state, or null when the media is not relayed.
|
||||
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||
final r = state.media?['relay'];
|
||||
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||
}
|
||||
|
||||
/// "4.1 MB/s", "850 kB/s".
|
||||
String formatRate(int bytesPerSec) {
|
||||
if (bytesPerSec >= 1000000) {
|
||||
@@ -50,6 +56,9 @@ enum SourceState {
|
||||
slow,
|
||||
/// Its channel is down (or the server has not read the link yet).
|
||||
waiting,
|
||||
/// The source member's connection dropped (`gone`, within its grace):
|
||||
/// viewers play what the server has.
|
||||
gone,
|
||||
/// It left the server.
|
||||
left,
|
||||
}
|
||||
@@ -114,6 +123,9 @@ class Delivery {
|
||||
final String sourceName;
|
||||
final bool sourceIsSelf;
|
||||
final SourceState state;
|
||||
/// [SourceState.gone]: since when; and the time the line is read at.
|
||||
final DateTime? goneSince;
|
||||
final DateTime? at;
|
||||
/// `media.relay.rate` / `need` (0 unknown).
|
||||
final int rate, need;
|
||||
/// Members (not sitting out, not the source) reading from the server,
|
||||
@@ -125,6 +137,8 @@ class Delivery {
|
||||
this.sourceName = '',
|
||||
this.sourceIsSelf = false,
|
||||
this.state = SourceState.ok,
|
||||
this.goneSince,
|
||||
this.at,
|
||||
this.rate = 0,
|
||||
this.need = 0,
|
||||
this.viaServer = 0,
|
||||
@@ -155,7 +169,10 @@ class Delivery {
|
||||
};
|
||||
case DeliveryKind.memberRelay:
|
||||
return switch (state) {
|
||||
SourceState.left => '$sourceName left: playing what the server has',
|
||||
SourceState.left => '$sourceName left — playing what the server has',
|
||||
SourceState.gone => sourceIsSelf
|
||||
? 'Reconnecting you to the server…'
|
||||
: "$sourceName's connection dropped — playing what the server has${_for()}",
|
||||
SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect',
|
||||
SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video",
|
||||
SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server',
|
||||
@@ -163,6 +180,12 @@ class Delivery {
|
||||
}
|
||||
}
|
||||
|
||||
String _for() {
|
||||
final since = goneSince, now = at;
|
||||
if (since == null || now == null || now.difference(since).inMinutes < 1) return '';
|
||||
return ' (${sinceText(since, now)})';
|
||||
}
|
||||
|
||||
/// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when
|
||||
/// there is nothing to add.
|
||||
String get detail {
|
||||
@@ -170,7 +193,7 @@ class Delivery {
|
||||
if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay))
|
||||
'$direct with their own account',
|
||||
if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server',
|
||||
if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
||||
if (rate > 0 && state != SourceState.left && state != SourceState.gone) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate),
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -193,8 +216,9 @@ class Delivery {
|
||||
}
|
||||
|
||||
/// The current media's [Delivery]; null with nothing up or nobody else in
|
||||
/// the server. [names] remembers a source that left.
|
||||
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) {
|
||||
/// the server. [names] remembers a source that left; [now] reads how long
|
||||
/// a gone source has been gone.
|
||||
Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names, DateTime? now}) {
|
||||
final media = r.media;
|
||||
if (media == null || r.members.length < 2) return null;
|
||||
final rel = media['relay'];
|
||||
@@ -237,6 +261,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
||||
? SourceState.ok
|
||||
: kind == DeliveryKind.memberRelay && source == null
|
||||
? SourceState.left
|
||||
: kind == DeliveryKind.memberRelay && (relay.sourceGone || source?.presence == Presence.gone)
|
||||
? SourceState.gone
|
||||
: !relay.live
|
||||
? SourceState.waiting
|
||||
: relay.slow
|
||||
@@ -248,6 +274,8 @@ Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? nam
|
||||
sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on',
|
||||
sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId,
|
||||
state: state,
|
||||
goneSince: state == SourceState.gone ? relay?.sourceGoneSince ?? source?.goneSince : null,
|
||||
at: now ?? DateTime.now().toUtc(),
|
||||
rate: relay?.rate ?? 0,
|
||||
need: relay?.need ?? 0,
|
||||
viaServer: via,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
enum HealthSeverity { ok, info, warn, error }
|
||||
|
||||
class HealthStatus {
|
||||
final HealthSeverity severity;
|
||||
final String message;
|
||||
const HealthStatus(this.severity, this.message);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The user's notification choices (Settings → Notifications & sounds), as
|
||||
// the Notifier's policy reads them. Pure Dart; [AppSettings] stores them.
|
||||
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// What the user picked: chat pop-ups, and whether joins and leaves also
|
||||
/// reach them while they sit out or the window is in the background
|
||||
/// ([people]; off by default: then only chat, nudges, "starting now" and
|
||||
/// Up next do).
|
||||
class NotifyPrefs {
|
||||
final ChatPopups chat;
|
||||
final bool people;
|
||||
const NotifyPrefs({this.chat = ChatPopups.all, this.people = false});
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:signals/signals_core.dart';
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||
import 'package:sync_relay_core/core.dart' show FeedMessage, Presence;
|
||||
|
||||
import '../platform/ports/system_notifications.dart';
|
||||
import '../platform/ports/window_activity.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import 'app_events.dart';
|
||||
import 'notice_prefs.dart';
|
||||
|
||||
export 'app_events.dart';
|
||||
export 'notice_prefs.dart';
|
||||
|
||||
class ToastItem {
|
||||
final int id;
|
||||
@@ -17,67 +21,243 @@ class ToastItem {
|
||||
final String? actor;
|
||||
/// [actor]'s member id (feed and chat toasts); '' when unknown.
|
||||
final String actorId;
|
||||
const ToastItem({required this.id, required this.text, required this.at, this.actor, this.actorId = ''});
|
||||
/// A chat message: a click opens the chat.
|
||||
final bool chat;
|
||||
/// A picture hash to show (an Up next item's), '' when none.
|
||||
final String thumb;
|
||||
const ToastItem(
|
||||
{required this.id,
|
||||
required this.text,
|
||||
required this.at,
|
||||
this.actor,
|
||||
this.actorId = '',
|
||||
this.chat = false,
|
||||
this.thumb = ''});
|
||||
}
|
||||
|
||||
/// What a notice is about; the policy filters by it ([noticeFor]).
|
||||
enum NoticeCategory {
|
||||
/// Chat messages.
|
||||
chat,
|
||||
|
||||
/// Someone poked this member.
|
||||
nudge,
|
||||
|
||||
/// The media starts playing for the first time ("starting now").
|
||||
start,
|
||||
|
||||
/// Up next and the video changing (adds, skips, swaps).
|
||||
upNext,
|
||||
|
||||
/// Joins, leaves, lost signal and returns.
|
||||
people,
|
||||
|
||||
/// The rest of what the party does (ready, seeks, pauses, someone sat
|
||||
/// out, renames): the activity log and a few sounds.
|
||||
party,
|
||||
|
||||
/// Something keeps the watch from going smoothly (someone cannot play
|
||||
/// it, the source dropped, the server could not fetch it).
|
||||
problem,
|
||||
|
||||
/// About this member itself (sat out, auto sit-out, the relay it
|
||||
/// serves, its connection, another window took over).
|
||||
self,
|
||||
|
||||
/// The app's own notices (copied, shaders, update, errors): never
|
||||
/// filtered.
|
||||
app;
|
||||
|
||||
/// What reaches you while you sit out or the window is in the
|
||||
/// background (D1): chat, nudges, "starting now" and Up next, plus joins
|
||||
/// and leaves when [NotifyPrefs.people] is on.
|
||||
bool reachesAway(NotifyPrefs prefs) => switch (this) {
|
||||
chat || nudge || start || upNext || self || app => true,
|
||||
people => prefs.people,
|
||||
party || problem => false,
|
||||
};
|
||||
|
||||
/// Social: nothing of it plays or pops up while you watch alone (D9).
|
||||
bool get social => this == chat || this == start || this == people || this == party;
|
||||
|
||||
/// May go out as a system notification while the window is in the
|
||||
/// background.
|
||||
bool get system => this == chat || this == nudge || this == start || this == upNext || this == people;
|
||||
}
|
||||
|
||||
/// The session as the policy sees it.
|
||||
typedef NoticeSession = ({Presence? self, bool alone, bool playing});
|
||||
|
||||
/// Where the user stands when a notice comes: their presence ([self],
|
||||
/// null outside a session), whether anyone else is there ([alone]), the
|
||||
/// window ([window]), whether the video plays ([playing]) and their
|
||||
/// choices ([prefs]). A replayed feed line says so itself.
|
||||
class NoticeContext {
|
||||
final Presence? self;
|
||||
final bool alone;
|
||||
final WindowActivity window;
|
||||
final bool playing;
|
||||
final NotifyPrefs prefs;
|
||||
const NoticeContext({
|
||||
this.self,
|
||||
this.alone = false,
|
||||
this.window = WindowActivity.focused,
|
||||
this.playing = false,
|
||||
this.prefs = const NotifyPrefs(),
|
||||
});
|
||||
|
||||
bool get away => self == Presence.away;
|
||||
}
|
||||
|
||||
/// A notification outside the window ([SystemNotifications]).
|
||||
class SystemNote {
|
||||
final String title;
|
||||
final String body;
|
||||
final String tag;
|
||||
/// [payloadChat]: a click opens the chat.
|
||||
final String payload;
|
||||
const SystemNote({required this.title, required this.body, this.tag = '', this.payload = ''});
|
||||
|
||||
static const payloadChat = 'chat';
|
||||
}
|
||||
|
||||
/// What one [AppEvent] does: a toast ([toast], about [actor]), a sound,
|
||||
/// the window asking for attention, an activity-log line.
|
||||
/// the window asking for attention, a system notification, an
|
||||
/// activity-log line.
|
||||
class Notice {
|
||||
final NoticeCategory category;
|
||||
final String? toast;
|
||||
final String? actor;
|
||||
/// The toast is a chat message (a click opens the chat).
|
||||
final bool chat;
|
||||
/// A picture hash for the toast (an Up next item's).
|
||||
final String thumb;
|
||||
final SfxCue? sound;
|
||||
final bool attention;
|
||||
final SystemNote? system;
|
||||
/// The feed line it goes into the activity log as.
|
||||
final FeedMessage? log;
|
||||
const Notice({this.toast, this.actor, this.sound, this.attention = false, this.log});
|
||||
const Notice({
|
||||
this.category = NoticeCategory.app,
|
||||
this.toast,
|
||||
this.actor,
|
||||
this.chat = false,
|
||||
this.thumb = '',
|
||||
this.sound,
|
||||
this.attention = false,
|
||||
this.system,
|
||||
this.log,
|
||||
});
|
||||
|
||||
static const none = Notice();
|
||||
|
||||
Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice(
|
||||
category: category,
|
||||
toast: toast ? this.toast : null,
|
||||
actor: toast ? actor : null,
|
||||
chat: toast && chat,
|
||||
thumb: toast ? thumb : '',
|
||||
sound: sound ? this.sound : null,
|
||||
attention: attention,
|
||||
system: system,
|
||||
log: log,
|
||||
);
|
||||
}
|
||||
|
||||
/// The policy: what each event does.
|
||||
Notice noticeFor(AppEvent e) => switch (e) {
|
||||
// Lines a reconnect replayed (`welcome.feed`): the log only.
|
||||
FeedEvent(:final feed) when feed.replayed => Notice(log: feed),
|
||||
// The feed: everything is logged; joins, leaves, swaps and chat
|
||||
// pop up; the social actions have a sound.
|
||||
String _title(String t) => t.isEmpty ? 'the video' : t;
|
||||
|
||||
/// The event alone: its category, toast, sound and log line.
|
||||
Notice _plain(AppEvent e) => switch (e) {
|
||||
// The feed: everything is logged.
|
||||
MemberJoined(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} joined', actor: feed.actor, sound: const SfxCue(SfxKind.join)),
|
||||
category: NoticeCategory.people,
|
||||
log: feed,
|
||||
toast: '${feed.actor} joined',
|
||||
actor: feed.actor,
|
||||
sound: const SfxCue(SfxKind.join)),
|
||||
MemberLeft(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} left', actor: feed.actor, sound: const SfxCue(SfxKind.leave)),
|
||||
category: NoticeCategory.people,
|
||||
log: feed,
|
||||
toast: '${feed.actor} left',
|
||||
actor: feed.actor,
|
||||
sound: const SfxCue(SfxKind.leave)),
|
||||
// After the server's delayed `gone` line (a quick reconnect says
|
||||
// nothing), and back after it.
|
||||
MemberGone(:final feed) => Notice(
|
||||
category: NoticeCategory.people, log: feed, toast: '${feed.actor} lost connection', actor: feed.actor),
|
||||
MemberReturned(:final feed) =>
|
||||
Notice(category: NoticeCategory.people, log: feed, toast: '${feed.actor} is back', actor: feed.actor),
|
||||
MediaSwapped(:final feed) => Notice(
|
||||
log: feed, toast: '${feed.actor} changed media', actor: feed.actor, sound: const SfxCue(SfxKind.swap)),
|
||||
ChatPosted(:final feed, :final own) => Notice(
|
||||
category: NoticeCategory.upNext,
|
||||
log: feed,
|
||||
toast: '${feed.actor} changed media',
|
||||
actor: feed.actor,
|
||||
sound: const SfxCue(SfxKind.swap)),
|
||||
QueueAdded(:final feed, :final thumb) => Notice(
|
||||
category: NoticeCategory.upNext,
|
||||
log: feed,
|
||||
toast: '${feed.actor} added ${_title(feed.target ?? '')} to Up next',
|
||||
actor: feed.actor,
|
||||
thumb: thumb),
|
||||
QueueSkipped(:final feed) => Notice(
|
||||
category: NoticeCategory.upNext,
|
||||
log: feed,
|
||||
toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||
QueueRemoved(:final feed) => Notice(category: NoticeCategory.upNext, log: feed),
|
||||
ChatPosted(:final feed) => Notice(
|
||||
category: NoticeCategory.chat,
|
||||
log: feed,
|
||||
toast: '${feed.actor}: ${feed.reason}',
|
||||
actor: feed.actor,
|
||||
// Your own message needs no sound.
|
||||
sound: own ? null : const SfxCue(SfxKind.chat)),
|
||||
chat: true,
|
||||
sound: const SfxCue(SfxKind.chat)),
|
||||
MemberSatOut(:final feed, atYou: true) => Notice(
|
||||
log: feed, toast: '${feed.actor} sat you out', actor: feed.actor, sound: const SfxCue(SfxKind.kick)),
|
||||
category: NoticeCategory.self,
|
||||
log: feed,
|
||||
toast: '${feed.actor} sat you out',
|
||||
actor: feed.actor,
|
||||
sound: const SfxCue(SfxKind.kick)),
|
||||
MemberSatOut(:final feed, autoSelf: true) => Notice(
|
||||
category: NoticeCategory.self,
|
||||
log: feed,
|
||||
toast: 'You sit out (${feed.reason == 'minimised' ? 'minimised' : 'idle'}); you are back as soon as you return',
|
||||
sound: const SfxCue(SfxKind.warn)),
|
||||
ReadyChanged(:final feed, ready: true, :final readyIndex) =>
|
||||
Notice(log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||
ReadyChanged(:final feed, ready: false) => Notice(log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||
ForcedReady(:final feed, ready: true) => Notice(log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||
QueueSkipped(:final feed) => Notice(
|
||||
log: feed, toast: 'Skipped ${feed.target ?? 'an item'}${feed.reason.isEmpty ? '' : ': ${feed.reason}'}'),
|
||||
FeedEvent(:final feed) => Notice(log: feed),
|
||||
Notice(category: NoticeCategory.party, log: feed, sound: SfxCue(SfxKind.ready, readyIndex: readyIndex)),
|
||||
ReadyChanged(:final feed, ready: false) =>
|
||||
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.unready)),
|
||||
ForcedReady(:final feed, ready: true) =>
|
||||
Notice(category: NoticeCategory.party, log: feed, sound: const SfxCue(SfxKind.forceReady)),
|
||||
FetchFailed(:final feed) => Notice(category: NoticeCategory.problem, log: feed),
|
||||
FeedEvent(:final feed) => Notice(category: NoticeCategory.party, log: feed),
|
||||
// The session.
|
||||
Nudged(:final name) => Notice(
|
||||
category: NoticeCategory.nudge,
|
||||
toast: '${name.isEmpty ? 'Someone' : name} nudged you',
|
||||
actor: name.isEmpty ? null : name,
|
||||
sound: const SfxCue(SfxKind.nudge),
|
||||
attention: true),
|
||||
MemberCantPlay(:final name, :final error) => Notice(toast: "$name can't play this: $error", actor: name),
|
||||
SlowSource(:final text) => Notice(toast: text),
|
||||
RelayStarted() =>
|
||||
const Notice(toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
RelayFailed(:final error) => Notice(toast: 'Cannot pass the video on: $error'),
|
||||
ConnectionLost() => const Notice(sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(sound: SfxCue(SfxKind.back)),
|
||||
PlaybackStarted(:final title) =>
|
||||
Notice(category: NoticeCategory.start, toast: title.isEmpty ? 'Starting now' : 'Starting now: $title'),
|
||||
MemberCantPlay(:final name, :final error) =>
|
||||
Notice(category: NoticeCategory.problem, toast: "$name can't play this: $error", actor: name),
|
||||
RelaySourceDropped(:final name, :final left) => Notice(
|
||||
category: NoticeCategory.problem,
|
||||
toast: left
|
||||
? '$name left: playing what the server has'
|
||||
: "$name's connection dropped: playing what the server has",
|
||||
actor: name),
|
||||
SlowSource(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||
RelayStarted() => const Notice(
|
||||
category: NoticeCategory.self,
|
||||
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
|
||||
RelayFailed(:final error) => Notice(category: NoticeCategory.self, toast: 'Cannot pass the video on: $error'),
|
||||
ConnectionLost() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.lost)),
|
||||
ConnectionBack() => const Notice(category: NoticeCategory.self, sound: SfxCue(SfxKind.back)),
|
||||
ServerRestarted() =>
|
||||
const Notice(category: NoticeCategory.self, toast: 'The server restarted; you are back in'),
|
||||
Replaced() => const Notice(category: NoticeCategory.self, toast: 'Starsync was opened in another window'),
|
||||
ServerRefused(:final text) => Notice(category: NoticeCategory.self, toast: text),
|
||||
JellyfinProgressFailed(:final signInExpired) =>
|
||||
Notice(toast: 'Jellyfin progress not saved: ${signInExpired ? 'sign-in expired' : 'server unreachable'}'),
|
||||
// The app.
|
||||
@@ -89,29 +269,115 @@ Notice noticeFor(AppEvent e) => switch (e) {
|
||||
Notice(toast: inSession ? 'Marked in the session log' : "Marked in this app's log"),
|
||||
Copied(:final text) => Notice(toast: text),
|
||||
CopyFailed() => const Notice(toast: "Couldn't copy"),
|
||||
// The update band shows it.
|
||||
// The update band shows it (D8: a clear prompt, never over playback).
|
||||
UpdateAvailable() => Notice.none,
|
||||
};
|
||||
|
||||
/// The policy: what [e] does for a user in [c].
|
||||
/// - Lines a reconnect replayed (`welcome.feed`): the activity log only.
|
||||
/// - Your own joins, leaves, swaps and Up next adds: logged, no pop-up or
|
||||
/// sound (the app already said so); your own chat pops up, silently.
|
||||
/// - Alone (D9): no social sounds or pop-ups (nobody is there); the app's
|
||||
/// notices, problems and your own still show.
|
||||
/// - Sitting out (D1): only chat, nudges, "starting now", Up next, your
|
||||
/// own and the app's, plus joins and leaves when chosen
|
||||
/// ([NotifyPrefs.people]); the rest is logged only.
|
||||
/// - "Starting now" pops up only while you sit out or the window is in
|
||||
/// the background (otherwise you see it start).
|
||||
/// - Chat pop-ups follow [NotifyPrefs.chat]; the chat sound has its own
|
||||
/// switch (Settings → Sounds).
|
||||
/// - The window in the background: what reaches you there also goes out
|
||||
/// as a system notification (chat, nudges, "starting now", Up next;
|
||||
/// joins and leaves when chosen).
|
||||
Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) {
|
||||
if (e is FeedEvent && e.feed.replayed) return Notice(category: _plain(e).category, log: e.feed);
|
||||
final n = _plain(e);
|
||||
final cat = n.category;
|
||||
final own = e is FeedEvent && e.own;
|
||||
var toast = n.toast != null;
|
||||
var sound = n.sound != null;
|
||||
if (own && (cat == NoticeCategory.people || cat == NoticeCategory.upNext)) {
|
||||
toast = false;
|
||||
sound = false;
|
||||
}
|
||||
if (own && cat == NoticeCategory.chat) sound = false;
|
||||
if (c.alone && cat.social) {
|
||||
toast = false;
|
||||
sound = false;
|
||||
}
|
||||
if (c.away && !cat.reachesAway(c.prefs)) {
|
||||
toast = false;
|
||||
sound = false;
|
||||
}
|
||||
if (cat == NoticeCategory.start && !c.away && !c.window.background) toast = false;
|
||||
if (cat == NoticeCategory.chat && !c.prefs.chat.shows(playing: c.playing)) toast = false;
|
||||
SystemNote? system;
|
||||
if (toast && !own && c.window.background && cat.system && cat.reachesAway(c.prefs)) {
|
||||
final actor = n.actor;
|
||||
final text = n.toast!;
|
||||
final body = actor != null && text.startsWith(actor) ? text.substring(actor.length).replaceFirst(RegExp(r'^:? '), '') : text;
|
||||
system = SystemNote(
|
||||
title: actor ?? 'Starsync',
|
||||
body: actor == null ? text : body,
|
||||
tag: cat == NoticeCategory.chat ? '' : cat.name,
|
||||
payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '',
|
||||
);
|
||||
}
|
||||
return n._with(toast: toast, sound: sound, system: system);
|
||||
}
|
||||
|
||||
/// The one way news reaches the user: [notify] turns an [AppEvent] into
|
||||
/// toasts ([toasts]), sounds, an attention request and the activity log
|
||||
/// ([feedLog]) per [noticeFor].
|
||||
/// toasts ([toasts], each dropped after [toastLife] by the Notifier's
|
||||
/// own timer), sounds, an attention request, a system notification and
|
||||
/// the activity log ([feedLog]) per [noticeFor], in the context the
|
||||
/// session ([session]), the window ([window]) and the user's choices
|
||||
/// ([prefs]) give.
|
||||
class Notifier {
|
||||
final SfxService sfx;
|
||||
final Future<void> Function() attention;
|
||||
Notifier({this.sfx = const NoopSfxService(), Future<void> Function()? attention})
|
||||
: attention = attention ?? _noAttention;
|
||||
final SystemNotifications system;
|
||||
/// Brings the window up (a click on a system notification).
|
||||
final Future<void> Function() focusWindow;
|
||||
final Duration toastLife;
|
||||
/// The session's part of the context; set by the coordinator.
|
||||
NoticeSession Function() session = () => (self: null, alone: false, playing: false);
|
||||
NotifyPrefs Function() prefs;
|
||||
/// The window's activity ([ShellPort.watchActivity], wired in `main`).
|
||||
WindowActivity window = WindowActivity.focused;
|
||||
|
||||
static Future<void> _noAttention() async {}
|
||||
Notifier({
|
||||
this.sfx = const NoopSfxService(),
|
||||
Future<void> Function()? attention,
|
||||
this.system = const NoSystemNotifications(),
|
||||
Future<void> Function()? focusWindow,
|
||||
this.toastLife = const Duration(seconds: 5),
|
||||
NotifyPrefs Function()? prefs,
|
||||
}) : attention = attention ?? _nothing,
|
||||
focusWindow = focusWindow ?? _nothing,
|
||||
prefs = prefs ?? _defaultPrefs {
|
||||
system.onClick = _clicked;
|
||||
}
|
||||
|
||||
/// Toasts on screen (the shell shows them; [expire] drops old ones).
|
||||
static Future<void> _nothing() async {}
|
||||
static NotifyPrefs _defaultPrefs() => const NotifyPrefs();
|
||||
|
||||
/// Toasts on screen (the shell shows them).
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
/// The activity log: the last 100 feed lines.
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
/// Bumped when a click on a chat notification asks for the chat (the
|
||||
/// shell opens it).
|
||||
final chatRequests = signal(0);
|
||||
int _toastId = 0;
|
||||
final _timers = <int, Timer>{};
|
||||
|
||||
NoticeContext get context {
|
||||
final s = session();
|
||||
return NoticeContext(self: s.self, alone: s.alone, window: window, playing: s.playing, prefs: prefs());
|
||||
}
|
||||
|
||||
void notify(AppEvent e) {
|
||||
final n = noticeFor(e);
|
||||
final n = noticeFor(e, context);
|
||||
final log = n.log;
|
||||
if (log != null) {
|
||||
final all = [...feedLog.peek(), log];
|
||||
@@ -119,29 +385,47 @@ class Notifier {
|
||||
}
|
||||
final text = n.toast;
|
||||
if (text != null) {
|
||||
_toastId++;
|
||||
final id = ++_toastId;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
id: id,
|
||||
text: text,
|
||||
at: DateTime.now().toUtc(),
|
||||
actor: n.actor,
|
||||
actorId: n.actor == null ? '' : n.log?.actorId ?? ''),
|
||||
actorId: n.actor == null ? '' : n.log?.actorId ?? '',
|
||||
chat: n.chat,
|
||||
thumb: n.thumb),
|
||||
];
|
||||
_timers[id] = Timer(toastLife, () => dismiss(id));
|
||||
}
|
||||
final sound = n.sound;
|
||||
if (sound != null) unawaited(sfx.play(sound));
|
||||
if (n.attention) unawaited(attention());
|
||||
final note = n.system;
|
||||
if (note != null) {
|
||||
unawaited(system.show(title: note.title, body: note.body, tag: note.tag, payload: note.payload));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops toasts 4 s old or older.
|
||||
void expire(DateTime now) {
|
||||
toasts.value = toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
void _clicked(String payload) {
|
||||
unawaited(focusWindow());
|
||||
if (payload == SystemNote.payloadChat) chatRequests.value++;
|
||||
}
|
||||
|
||||
/// Takes toast [id] off the screen (its time is up, or it was clicked).
|
||||
void dismiss(int id) {
|
||||
_timers.remove(id)?.cancel();
|
||||
final now = toasts.peek();
|
||||
if (now.any((t) => t.id == id)) toasts.value = [for (final t in now) if (t.id != id) t];
|
||||
}
|
||||
|
||||
/// A new session: no toasts, an empty log.
|
||||
void clear() {
|
||||
for (final t in _timers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
_timers.clear();
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
@@ -28,16 +28,18 @@ import '../platform/ports/shell_port.dart' show WindowActivity;
|
||||
import 'auto_sit_out.dart';
|
||||
import 'connection.dart';
|
||||
import 'delivery.dart';
|
||||
import 'health.dart';
|
||||
import 'media_art.dart';
|
||||
import 'notifier.dart';
|
||||
import 'now_playing.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'party.dart';
|
||||
import 'status.dart';
|
||||
import 'switch_trace.dart';
|
||||
|
||||
export 'connection.dart' show ConnectCancelled, ConnectParams;
|
||||
export 'notifier.dart' show ToastItem;
|
||||
export 'delivery.dart' show stateMediaRelay;
|
||||
export 'status.dart';
|
||||
|
||||
final _log = createLogger('session');
|
||||
|
||||
@@ -71,12 +73,6 @@ bool unreadyForNewMedia(
|
||||
required bool ready}) =>
|
||||
enabled && lastMediaId >= 0 && mediaId != lastMediaId && ready;
|
||||
|
||||
/// `media.relay` of a state, or null when the media is not relayed.
|
||||
MediaRelay? stateMediaRelay(StateBroadcast state) {
|
||||
final r = state.media?['relay'];
|
||||
return r is Map ? MediaRelay.fromJson(Map<String, Object?>.from(r)) : null;
|
||||
}
|
||||
|
||||
/// What this member serves when its own swap is relayed: set at swap time,
|
||||
/// claimed by the state that names this member as the source.
|
||||
sealed class _RelayOrigin {
|
||||
@@ -103,19 +99,6 @@ final class _JellyfinOrigin extends _RelayOrigin {
|
||||
const _JellyfinOrigin(this.link);
|
||||
}
|
||||
|
||||
/// The health line a connection change writes; null writes none (closed:
|
||||
/// whoever closed says why).
|
||||
HealthStatus? connectionHealth(ConnState s) => switch (s) {
|
||||
ConnConnecting() => const HealthStatus(HealthSeverity.info, 'connecting…'),
|
||||
ConnOnline() => const HealthStatus(HealthSeverity.ok, 'connected'),
|
||||
ConnUnstable() => const HealthStatus(HealthSeverity.warn, 'connection unstable…'),
|
||||
ConnLost() => const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
||||
ConnReconnecting() => const HealthStatus(HealthSeverity.info, 'reconnecting…'),
|
||||
ConnRefused(:final message) => HealthStatus(HealthSeverity.error, message),
|
||||
ConnReplaced(:final message) => HealthStatus(HealthSeverity.error, message),
|
||||
ConnClosed() => null,
|
||||
};
|
||||
|
||||
String? stateMediaUrl(StateBroadcast state) {
|
||||
final media = state.media;
|
||||
if (media == null) return null;
|
||||
@@ -171,8 +154,6 @@ class SessionCoordinator {
|
||||
final SwitchTrace switchTrace = SwitchTrace();
|
||||
final StateDiff diff = StateDiff();
|
||||
|
||||
final health =
|
||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
/// The server's latest state. A drop keeps it (with the player, the
|
||||
/// media and a relay this member serves): [stateStale] says it is not
|
||||
/// current; only leaving clears it.
|
||||
@@ -191,7 +172,38 @@ class SessionCoordinator {
|
||||
late final Computed<bool> reconnecting = computed(() => connection.state.value.retrying);
|
||||
/// The link is up but went quiet ([ConnUnstable]).
|
||||
late final Computed<bool> unstable = computed(() => connection.state.value is ConnUnstable);
|
||||
final starving = signal<String?>(null);
|
||||
/// The player itself failed (a "Reload video" that failed); '' when
|
||||
/// fine. A fact for [status].
|
||||
final playerFault = signal('');
|
||||
/// The mediaId the server said it could not fetch (`fetch-fail`); -1.
|
||||
final fetchFailedFor = signal(-1);
|
||||
/// Reconnects that found the server restarted (another
|
||||
/// `welcome.instance`).
|
||||
final restarts = signal(0);
|
||||
// The status's clock, moved on by the tick when the minute changes (a
|
||||
// "(2 min)" in it).
|
||||
final _clock = signal(DateTime.now().toUtc());
|
||||
|
||||
/// The one computed status (session/status.dart): the player's pill,
|
||||
/// the strip's line, the members card's source line. Nothing sets it;
|
||||
/// [StatusWatch] turns its transitions into notices.
|
||||
late final Computed<Status> status = computed(() {
|
||||
final plan = jellyfinPlan.value;
|
||||
return statusOf(StatusFacts(
|
||||
conn: connection.state.value,
|
||||
state: serverState.value,
|
||||
selfId: connection.memberId,
|
||||
playerError: playerError.value,
|
||||
playerFault: playerFault.value,
|
||||
ownPlan: plan is OwnPlay,
|
||||
fetchFailedFor: fetchFailedFor.value,
|
||||
names: memberNames,
|
||||
restarts: restarts.value,
|
||||
now: _clock.value,
|
||||
));
|
||||
});
|
||||
final StatusWatch _statusWatch = StatusWatch();
|
||||
void Function()? _statusEffect;
|
||||
/// Why this player cannot open the current media, in plain words
|
||||
/// ([playErrorText]); empty while it can. It rides in `report.error`, so
|
||||
/// the server stops waiting for this member and the others see it.
|
||||
@@ -255,7 +267,6 @@ class SessionCoordinator {
|
||||
StreamSubscription? _feedSub;
|
||||
StreamSubscription? _nudgeSub;
|
||||
StreamSubscription? _errSub;
|
||||
void Function()? _connectionHealth;
|
||||
|
||||
/// A solo session (set by SessionState): no auto sit-out, there is
|
||||
/// nobody to hold up.
|
||||
@@ -372,6 +383,27 @@ class SessionCoordinator {
|
||||
connection
|
||||
..onWelcome = _onWelcome
|
||||
..caps = () => caps;
|
||||
// The Notifier's view of the session (Status → Notifier, never back).
|
||||
notifier.session = () {
|
||||
final r = serverState.peek();
|
||||
return (
|
||||
self: selfMember.peek()?.presence,
|
||||
alone: r != null && aloneIn(r.members, connection.memberId),
|
||||
playing: r?.transport == 'playing',
|
||||
);
|
||||
};
|
||||
_statusEffect = effect(() => _stepStatus(status.value));
|
||||
}
|
||||
|
||||
/// Status transitions worth a notice (see [StatusWatch]).
|
||||
void _stepStatus(Status s) {
|
||||
final events = _statusWatch.step(s, DateTime.now().toUtc());
|
||||
if (events.isEmpty) return;
|
||||
untracked(() {
|
||||
for (final e in events) {
|
||||
notify(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// yt-dlp is there to resolve links (checked at each connect).
|
||||
@@ -406,8 +438,6 @@ class SessionCoordinator {
|
||||
_playerReady = true;
|
||||
} catch (e, st) {
|
||||
debugPrint('player init failed: $e\n$st');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'player failed to start: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -465,6 +495,7 @@ class SessionCoordinator {
|
||||
if (connection.params == null || _reloadingVideo) return;
|
||||
_reloadingVideo = true;
|
||||
_playerReady = false;
|
||||
playerFault.value = '';
|
||||
try {
|
||||
await playback.recreate();
|
||||
final url = _openUrl;
|
||||
@@ -474,8 +505,7 @@ class SessionCoordinator {
|
||||
if (url != null) await _openMedia(url, headers: headers);
|
||||
} catch (e, st) {
|
||||
debugPrint('video reload failed: $e\n$st');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, 'video reload failed: $e');
|
||||
playerFault.value = 'video reload failed: $e';
|
||||
} finally {
|
||||
_playerReady = playback.alive.peek();
|
||||
_reloadingVideo = false;
|
||||
@@ -504,6 +534,10 @@ class SessionCoordinator {
|
||||
settledMediaId.value = -1;
|
||||
_openUrl = null;
|
||||
playerError.value = '';
|
||||
playerFault.value = '';
|
||||
fetchFailedFor.value = -1;
|
||||
restarts.value = 0;
|
||||
_statusWatch.reset();
|
||||
_memberErrors = const {};
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
@@ -535,12 +569,7 @@ class SessionCoordinator {
|
||||
_startTicker();
|
||||
try {
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
await _openMedia(url);
|
||||
} else {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.info, 'connected — waiting for host media…');
|
||||
}
|
||||
if (url != null && url.isNotEmpty) await _openMedia(url);
|
||||
final media = params.media ??
|
||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
@@ -549,8 +578,6 @@ class SessionCoordinator {
|
||||
if (!connection.isCurrent(epoch)) throw const ConnectCancelled();
|
||||
debugPrint('connect failed: $e');
|
||||
connection.scheduleReconnect();
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, connectErrorText(e));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -560,13 +587,8 @@ class SessionCoordinator {
|
||||
_stateSub ??= connection.states.listen(_onState);
|
||||
_feedSub ??= connection.feeds.listen(_onFeed);
|
||||
_nudgeSub ??= connection.nudges.listen(_onNudge);
|
||||
_errSub ??= connection.errors.listen((msg) {
|
||||
health.value = HealthStatus(HealthSeverity.warn, msg);
|
||||
});
|
||||
_connectionHealth ??= connection.state.subscribe((s) {
|
||||
final h = connectionHealth(s);
|
||||
if (h != null) health.value = h;
|
||||
});
|
||||
// The server refused something this app sent: a toast.
|
||||
_errSub ??= connection.errors.listen((msg) => notify(ServerRefused(_sentenceCase(msg))));
|
||||
}
|
||||
|
||||
/// Opens [url] (with [headers] on every request); false on failure. A
|
||||
@@ -593,8 +615,6 @@ class SessionCoordinator {
|
||||
keepPlayer: keepPlayer,
|
||||
onFreshPlayer: () => switchTrace.mark(mediaId, SwitchTrace.player));
|
||||
switchTrace.mark(mediaId, promoted ? SwitchTrace.preloaded : SwitchTrace.opened);
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
ok = true;
|
||||
} catch (e) {
|
||||
debugPrint('open media failed: $e');
|
||||
@@ -688,8 +708,6 @@ class SessionCoordinator {
|
||||
return playback.preload(url, headers: headers, ytdlPath: path, fresh: fresh);
|
||||
}
|
||||
|
||||
bool _relayWaitShown = false;
|
||||
|
||||
/// The server-fetched media this viewer opens once it is live.
|
||||
int _awaitLiveFor = -1;
|
||||
|
||||
@@ -700,33 +718,6 @@ class SessionCoordinator {
|
||||
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
|
||||
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
||||
|
||||
/// Relayed through a member whose channel is down: say who everyone is
|
||||
/// waiting for (the buffer still plays what it has).
|
||||
void _relayStatus(StateBroadcast r) {
|
||||
final relay = stateMediaRelay(r);
|
||||
final waiting = relay != null &&
|
||||
relay.source.isNotEmpty &&
|
||||
relay.source != connection.memberId &&
|
||||
!relay.live &&
|
||||
_openUrl != null &&
|
||||
jellyfinPlan.peek() is! OwnPlay;
|
||||
if (waiting) {
|
||||
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
|
||||
final gone = memberNames[relay.source] ?? 'the member who put this up';
|
||||
health.value = HealthStatus(
|
||||
HealthSeverity.warn,
|
||||
who == null
|
||||
? '$gone left: playing what the server has'
|
||||
: relay.sourceGone
|
||||
? '$who lost signal: playing what the server has'
|
||||
: 'waiting for $who to pass the video on');
|
||||
_relayWaitShown = true;
|
||||
} else if (_relayWaitShown) {
|
||||
_relayWaitShown = false;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
}
|
||||
}
|
||||
|
||||
/// This viewer's `/r/` URL; with tracks, a pieces track's playlist, or
|
||||
/// an mpv EDL of every track when there are several (a page's separate
|
||||
/// video and audio). A relay started in Up next ([queueId]) keeps its
|
||||
@@ -948,8 +939,6 @@ class SessionCoordinator {
|
||||
_awaitLiveFor = r.mediaId;
|
||||
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
|
||||
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
|
||||
health.value = HealthStatus(HealthSeverity.info,
|
||||
relay.fetch ? 'the server is getting the video…' : 'waiting for $who to pass the video on…');
|
||||
} else {
|
||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
|
||||
}
|
||||
@@ -976,10 +965,8 @@ class SessionCoordinator {
|
||||
_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId, relay.tracks, relay.queueId));
|
||||
}
|
||||
_jellyfinTransport(r);
|
||||
_relayStatus(r);
|
||||
if (!_playerReady || _openUrl == null) {
|
||||
_wasPlayAllowed = false;
|
||||
_updateStarving(r, 0);
|
||||
return;
|
||||
}
|
||||
// Server clock: serverTime in the state is the server's.
|
||||
@@ -1014,7 +1001,6 @@ class SessionCoordinator {
|
||||
_seekRetries = 0;
|
||||
}
|
||||
await playback.apply(actions);
|
||||
_updateStarving(r, playback.bufferedSecsAhead);
|
||||
}
|
||||
|
||||
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||
@@ -1051,7 +1037,6 @@ class SessionCoordinator {
|
||||
Future<void> _unloadForOut() async {
|
||||
_wasPlayAllowed = false;
|
||||
_awaitLiveFor = -1;
|
||||
starving.value = null;
|
||||
if (_mediaId == -1 && _openUrl == null) return;
|
||||
_mediaId = -1;
|
||||
_playerMediaId = -1;
|
||||
@@ -1085,61 +1070,30 @@ class SessionCoordinator {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||
if (r.transport == 'playing') {
|
||||
starving.value = null;
|
||||
return;
|
||||
}
|
||||
if (r.transport == 'finished') {
|
||||
starving.value = 'finished — seek back or change media';
|
||||
return;
|
||||
}
|
||||
for (final m in r.members) {
|
||||
if (m.inSet && m.buffered < r.bufferNeed) {
|
||||
starving.value =
|
||||
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
|
||||
return;
|
||||
}
|
||||
}
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
// Who the ready counter counts (not away, not gone, can play it).
|
||||
final present = r.members.where(countsForReady).toList();
|
||||
final waiters = present
|
||||
.where((m) => m.id != self.id && !(m.inSet && m.ready))
|
||||
.map((m) => m.name)
|
||||
.toList();
|
||||
if (waiters.isNotEmpty) {
|
||||
final readyCount = present.where((m) => m.ready).length;
|
||||
starving.value =
|
||||
'waiting for ${waiters.join(', ')} ($readyCount/${present.length} ready)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (self != null && !self.ready) {
|
||||
starving.value = 'press Space when ready';
|
||||
return;
|
||||
}
|
||||
if (r.members.any((m) => m.inSet)) {
|
||||
starving.value = 'held — waiting for readiness';
|
||||
return;
|
||||
}
|
||||
starving.value = 'held — press Space when ready';
|
||||
}
|
||||
|
||||
void _onFeed(FeedMessage f) {
|
||||
final r = serverState.peek();
|
||||
final event = FeedEvent.of(f,
|
||||
selfId: connection.memberId,
|
||||
readyCount: f.action == 'ready'
|
||||
? serverState.peek()?.members.where((m) => m.ready && countsForReady(m)).length ?? 0
|
||||
: 0);
|
||||
readyCount: f.action == 'ready' ? r?.members.where((m) => m.ready && countsForReady(m)).length ?? 0 : 0,
|
||||
// The state goes out before its feed: the item is in it.
|
||||
queueThumb: f.action == 'queue-add' ? _addedThumb(r, f) : '');
|
||||
// The server gave up fetching what everyone waits for (a fact for
|
||||
// [status]).
|
||||
if (event is FetchFailed && r != null && !f.replayed) fetchFailedFor.value = r.mediaId;
|
||||
notify(event);
|
||||
// The server gave up fetching what everyone waits for.
|
||||
if (event is FetchFailed && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) {
|
||||
health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video");
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of the Up next item [f] (`queue-add`) added: the newest
|
||||
/// item of that title by that member.
|
||||
static String _addedThumb(StateBroadcast? r, FeedMessage f) {
|
||||
for (final q in (r?.queue ?? const <QueueItem>[]).reversed) {
|
||||
if (q.title == f.target && (f.actorId.isEmpty || q.by == f.actorId)) return q.thumb;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static String _sentenceCase(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||
|
||||
/// Someone poked this member: sound (even when its switch is off; the
|
||||
/// volume still applies), a toast, and the window asks for attention.
|
||||
void _onNudge(NudgeNotice n) => notify(Nudged(n.name));
|
||||
@@ -1176,7 +1130,10 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> _tick() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
notifier.expire(now);
|
||||
// The status's clock moves by the minute; its transitions (a source
|
||||
// gone long enough) are checked every second.
|
||||
if (now.difference(_clock.peek()).inMinutes >= 1 || now.minute != _clock.peek().minute) _clock.value = now;
|
||||
_stepStatus(status.peek());
|
||||
final r = serverState.peek();
|
||||
final online = connection.state.peek().online;
|
||||
if (online && connection.token.isNotEmpty && r != null && _playerReady) {
|
||||
@@ -2038,6 +1995,7 @@ class SessionCoordinator {
|
||||
if (!rejoin.reconnect) return;
|
||||
_log.info('reconnected: ${rejoin.resumed ? 'resumed' : 'a new member'}'
|
||||
'${rejoin.sameServer ? '' : ' (the server restarted)'}');
|
||||
if (!rejoin.sameServer) restarts.value++;
|
||||
if (rejoin.resumed) return;
|
||||
final before = serverState.peek();
|
||||
final raw = welcome.state;
|
||||
@@ -2107,16 +2065,9 @@ class SessionCoordinator {
|
||||
/// "Stop reconnecting".
|
||||
void cancelReconnect() => connection.cancelReconnect();
|
||||
|
||||
Future<void> cancelConnect() async {
|
||||
await connection.cancelConnect();
|
||||
health.value = const HealthStatus(HealthSeverity.info, 'cancelled');
|
||||
}
|
||||
Future<void> cancelConnect() => connection.cancelConnect();
|
||||
|
||||
void retry() {
|
||||
if (!connection.retry()) return;
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.info, 'retrying…');
|
||||
}
|
||||
void retry() => connection.retry();
|
||||
|
||||
/// App close, before [leave]: the players go first (no mpv may outlive
|
||||
/// the engine). The Jellyfin stop report [leave] sends keeps the
|
||||
@@ -2158,11 +2109,7 @@ class SessionCoordinator {
|
||||
} catch (_) {}
|
||||
// The last lines go before the token does, then the bye.
|
||||
await connection.close(bye: bye);
|
||||
if (!silent) {
|
||||
serverState.value = null;
|
||||
starving.value = null;
|
||||
health.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||
}
|
||||
if (!silent) serverState.value = null;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
@@ -2175,7 +2122,7 @@ class SessionCoordinator {
|
||||
await _feedSub?.cancel();
|
||||
await _nudgeSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
_connectionHealth?.call();
|
||||
_statusEffect?.call();
|
||||
_bufferSub?.call();
|
||||
_aniList?.close();
|
||||
await connection.dispose();
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'app_events.dart';
|
||||
import 'connection.dart';
|
||||
import 'delivery.dart';
|
||||
import 'now_playing_info.dart';
|
||||
import 'party.dart';
|
||||
|
||||
// The session's one computed status: what the player's status pill, the
|
||||
// party strip's server line and the members card's source line say, from
|
||||
// facts only (the connection, the server's state with its hold reason and
|
||||
// relay, this player's open/error facts). Nothing writes a status; the
|
||||
// coordinator computes it ([statusOf]) and [StatusWatch] turns the
|
||||
// transitions worth a notice into [AppEvent]s for the Notifier (Status →
|
||||
// Notifier, never back).
|
||||
|
||||
/// How bad it is: [ok] and [info] never colour anything, [warn] amber,
|
||||
/// [error] red.
|
||||
enum StatusLevel { ok, info, warn, error }
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { reconnecting, problem, connecting, fetching, finished, loading, you, cannotPlay, waiting }
|
||||
|
||||
/// 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], its [level], the members it
|
||||
/// is about ([who]; [self] for you) and a loading [progress] (0..1).
|
||||
class StatusLine {
|
||||
final StatusKind kind;
|
||||
final String text;
|
||||
final String? detail;
|
||||
final StatusLevel level;
|
||||
final List<MemberView> who;
|
||||
final bool self;
|
||||
final double? progress;
|
||||
final StatusAction? action;
|
||||
/// The mini player's shorter line.
|
||||
final String short;
|
||||
const StatusLine(
|
||||
this.kind,
|
||||
this.text, {
|
||||
this.detail,
|
||||
this.level = StatusLevel.info,
|
||||
this.who = const [],
|
||||
this.self = false,
|
||||
this.progress,
|
||||
this.action,
|
||||
String? short,
|
||||
}) : short = short ?? text;
|
||||
|
||||
/// Coloured: a problem.
|
||||
bool get problem => level == StatusLevel.warn || level == StatusLevel.error;
|
||||
}
|
||||
|
||||
/// The facts a status is computed from.
|
||||
class StatusFacts {
|
||||
final ConnState conn;
|
||||
/// The server's latest state (kept through a drop).
|
||||
final StateBroadcast? state;
|
||||
/// This member's id ('' before the welcome).
|
||||
final String selfId;
|
||||
/// Why this player cannot open the current media ([playErrorText]).
|
||||
final String playerError;
|
||||
/// The player itself failed (a reload that failed); '' when fine.
|
||||
final String playerFault;
|
||||
/// This viewer plays the current Jellyfin item with its own account:
|
||||
/// the relay's troubles are not its own.
|
||||
final bool ownPlan;
|
||||
/// The mediaId the server said it could not fetch (`fetch-fail`), -1.
|
||||
final int fetchFailedFor;
|
||||
/// Names of members that left (a relay source that left).
|
||||
final MemberNames? names;
|
||||
/// A reconnect found the server restarted this many times.
|
||||
final int restarts;
|
||||
final DateTime now;
|
||||
const StatusFacts({
|
||||
this.conn = const ConnClosed(),
|
||||
this.state,
|
||||
this.selfId = '',
|
||||
this.playerError = '',
|
||||
this.playerFault = '',
|
||||
this.ownPlan = false,
|
||||
this.fetchFailedFor = -1,
|
||||
this.names,
|
||||
this.restarts = 0,
|
||||
required this.now,
|
||||
});
|
||||
}
|
||||
|
||||
/// The relay source's trouble, as the transitions watch it.
|
||||
class SourceTrouble {
|
||||
final int mediaId;
|
||||
final String sourceId;
|
||||
final String name;
|
||||
/// Gone since (the source member is `gone`); null when it left.
|
||||
final DateTime? since;
|
||||
bool get left => since == null;
|
||||
const SourceTrouble({required this.mediaId, required this.sourceId, required this.name, this.since});
|
||||
}
|
||||
|
||||
class Status {
|
||||
/// The player's pill; null when there is nothing to say (playing,
|
||||
/// connected, everyone ready).
|
||||
final StatusLine? line;
|
||||
/// The party strip's line under the server name: "connecting…",
|
||||
/// "reconnecting…", "ready 2/3".
|
||||
final String party;
|
||||
/// Where the current media comes from (the members card's source line);
|
||||
/// null with nothing up or nobody else there.
|
||||
final Delivery? delivery;
|
||||
/// The relay source this viewer depends on is gone or left.
|
||||
final SourceTrouble? source;
|
||||
final ConnState conn;
|
||||
final StateBroadcast? state;
|
||||
final int restarts;
|
||||
const Status({
|
||||
this.line,
|
||||
this.party = '',
|
||||
this.delivery,
|
||||
this.source,
|
||||
this.conn = const ConnClosed(),
|
||||
this.state,
|
||||
this.restarts = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// "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);
|
||||
|
||||
/// "(2 min)" from a minute on, nothing before.
|
||||
String _forHow(DateTime since, DateTime now) =>
|
||||
now.difference(since).inMinutes >= 1 ? ' (${sinceText(since, now)})' : '';
|
||||
|
||||
/// "Bob's connection dropped — playing what the server has (2 min)",
|
||||
/// "Bob left — playing what the server has".
|
||||
String sourceTroubleText(SourceTrouble t, DateTime now) => t.left
|
||||
? '${t.name} left — playing what the server has'
|
||||
: "${t.name}'s connection dropped — playing what the server has${_forHow(t.since!, now)}";
|
||||
|
||||
/// The relayed media's source this viewer reads from is gone or left.
|
||||
SourceTrouble? sourceTroubleOf(StateBroadcast r, String selfId, MemberNames? names) {
|
||||
final relay = stateMediaRelay(r);
|
||||
if (relay == null || relay.fetch || relay.source.isEmpty || relay.source == selfId) return null;
|
||||
final m = r.members.where((m) => m.id == relay.source).firstOrNull;
|
||||
final name = m?.name ?? names?[relay.source] ?? 'The member passing it on';
|
||||
if (m == null) return SourceTrouble(mediaId: r.mediaId, sourceId: relay.source, name: name);
|
||||
if (!relay.sourceGone && m.presence != Presence.gone) return null;
|
||||
return SourceTrouble(
|
||||
mediaId: r.mediaId,
|
||||
sourceId: relay.source,
|
||||
name: name,
|
||||
since: relay.sourceGoneSince ?? m.goneSince ?? r.serverTime);
|
||||
}
|
||||
|
||||
/// The status from [f].
|
||||
Status statusOf(StatusFacts f) {
|
||||
final r = f.state;
|
||||
final stale = r != null && (f.conn.retrying || f.conn is ConnReplaced || f.conn is ConnRefused);
|
||||
final counted = (r?.members ?? const <MemberView>[]).where(countsForReady).toList();
|
||||
final party = r == null
|
||||
? 'connecting…'
|
||||
: stale
|
||||
? 'reconnecting…'
|
||||
: 'ready ${counted.where((m) => m.ready).length}/${counted.length}';
|
||||
final self = r == null || f.selfId.isEmpty ? null : r.members.where((m) => m.id == f.selfId).firstOrNull;
|
||||
final source = r == null || f.ownPlan ? null : sourceTroubleOf(r, f.selfId, f.names);
|
||||
return Status(
|
||||
line: _line(f, r, self, source),
|
||||
party: party,
|
||||
delivery: r == null ? null : deliveryOf(r, selfId: f.selfId, names: f.names, now: f.now),
|
||||
source: source,
|
||||
conn: f.conn,
|
||||
state: r,
|
||||
restarts: f.restarts,
|
||||
);
|
||||
}
|
||||
|
||||
StatusLine? _line(StatusFacts f, StateBroadcast? r, MemberView? self, SourceTrouble? source) {
|
||||
switch (f.conn) {
|
||||
case ConnLost() || ConnReconnecting():
|
||||
return const StatusLine(StatusKind.reconnecting, 'Reconnecting…',
|
||||
level: StatusLevel.warn, action: StatusAction.cancel);
|
||||
case ConnUnstable():
|
||||
return const StatusLine(StatusKind.reconnecting, 'Connection unstable…', level: StatusLevel.warn);
|
||||
case ConnReplaced(:final message) || ConnRefused(:final message):
|
||||
return StatusLine(StatusKind.problem, _sentence(message), level: StatusLevel.error, action: StatusAction.retry);
|
||||
case ConnConnecting():
|
||||
return const StatusLine(StatusKind.connecting, 'Connecting…');
|
||||
case ConnOnline() || ConnClosed():
|
||||
break;
|
||||
}
|
||||
final away = self?.presence == Presence.away;
|
||||
if (r == null || r.media == null) return null;
|
||||
// Sitting out: the join-back card says it; nobody waits for you.
|
||||
if (away) return null;
|
||||
if (f.playerFault.isNotEmpty) {
|
||||
return StatusLine(StatusKind.problem, _sentence(f.playerFault),
|
||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
if (f.playerError.isNotEmpty) {
|
||||
return StatusLine(StatusKind.problem, "Can't play this: ${f.playerError}",
|
||||
level: StatusLevel.error, action: StatusAction.reload, short: "Can't play this");
|
||||
}
|
||||
// The relay: what everyone reads comes through the server.
|
||||
final relay = f.ownPlan ? null : stateMediaRelay(r);
|
||||
if (relay != null && relay.source != f.selfId) {
|
||||
if (relay.fetch && !relay.live) {
|
||||
if (f.fetchFailedFor == r.mediaId) {
|
||||
return const StatusLine(StatusKind.problem, "The server couldn't fetch this video", level: StatusLevel.error);
|
||||
}
|
||||
return const StatusLine(StatusKind.fetching, 'The server is getting the video…');
|
||||
}
|
||||
if (source != null) {
|
||||
return StatusLine(StatusKind.problem, sourceTroubleText(source, f.now),
|
||||
level: StatusLevel.warn,
|
||||
short: source.left ? '${source.name} left' : "${source.name}'s connection dropped");
|
||||
}
|
||||
if (!relay.fetch && !relay.live) {
|
||||
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name ?? 'the member who put it up';
|
||||
return StatusLine(StatusKind.problem, 'Waiting for $who to pass the video on', level: StatusLevel.warn);
|
||||
}
|
||||
}
|
||||
if (r.transport == 'playing') return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusLine(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 StatusLine(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||
who: [m], self: you, progress: p, detail: '${(p * 100).round()}%');
|
||||
}
|
||||
}
|
||||
if (failed.isNotEmpty) {
|
||||
final first = failed.first;
|
||||
final who = nameList([for (final m in failed) m.name]);
|
||||
final unready = self != null && !self.ready;
|
||||
return StatusLine(StatusKind.cannotPlay, "$who can't play this: ${first.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 StatusLine(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 StatusLine(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
||||
who: others, detail: '$ready/${active.length} ready');
|
||||
}
|
||||
|
||||
/// How long the relay's source must be gone before it is worth a notice
|
||||
/// (a quick reconnect says nothing).
|
||||
const sourceDropNotice = Duration(seconds: 5);
|
||||
|
||||
/// Turns status transitions worth a notice into events: the source this
|
||||
/// viewer reads from gone for [sourceDropNotice] (or left), another window
|
||||
/// took over, the server restarted, the media starting for the first time.
|
||||
/// Idempotent: [step] may run on every status change and every second.
|
||||
class StatusWatch {
|
||||
final _dropped = <String>{};
|
||||
final _seenHeld = <int>{};
|
||||
final _started = <int>{};
|
||||
bool _replaced = false;
|
||||
int _restarts = 0;
|
||||
bool _first = true;
|
||||
|
||||
List<AppEvent> step(Status s, DateTime now) {
|
||||
final out = <AppEvent>[];
|
||||
final src = s.source;
|
||||
if (src != null) {
|
||||
final key = '${src.mediaId}/${src.sourceId}/${src.left ? 'left' : src.since!.toIso8601String()}';
|
||||
final due = src.left || now.difference(src.since!) >= sourceDropNotice;
|
||||
// Once per drop, and once when it left.
|
||||
if (due && _dropped.add(key)) out.add(RelaySourceDropped(src.name, left: src.left));
|
||||
}
|
||||
final replaced = s.conn is ConnReplaced;
|
||||
if (replaced && !_replaced) out.add(const Replaced());
|
||||
_replaced = replaced;
|
||||
if (!_first && s.restarts > _restarts) out.add(const ServerRestarted());
|
||||
_restarts = s.restarts;
|
||||
_first = false;
|
||||
final r = s.state;
|
||||
if (r != null && r.media != null) {
|
||||
if (r.transport == 'held') _seenHeld.add(r.mediaId);
|
||||
if (r.transport == 'playing' && _seenHeld.contains(r.mediaId) && _started.add(r.mediaId)) {
|
||||
final now = nowPlayingOf(r.media);
|
||||
out.add(PlaybackStarted([?now?.title, ?now?.episode].join(' · ')));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// A new session.
|
||||
void reset() {
|
||||
_dropped.clear();
|
||||
_seenHeld.clear();
|
||||
_started.clear();
|
||||
_replaced = false;
|
||||
_restarts = 0;
|
||||
_first = true;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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