Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -16,7 +16,7 @@ import '../session/app_events.dart' show ActionFailed, ScheduleResult;
|
||||
import '../session/session_coordinator.dart' show ConnectParams;
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -202,7 +202,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final out = coord.selfOut.value;
|
||||
final out = coord.selfAway.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
@@ -323,7 +323,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
if (coord.selfAway.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
@@ -447,6 +447,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
|
||||
/// Ctrl+V outside a text field (a focused field pastes on its own).
|
||||
bool _onKey(KeyEvent e) {
|
||||
// Any key is activity (auto sit-out).
|
||||
SessionState.instance.coordinator.markInput();
|
||||
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
|
||||
@@ -675,9 +677,18 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Any pointer use anywhere is activity (auto sit-out).
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return Listener(
|
||||
onPointerHover: (e) => _pointer = e.localPosition,
|
||||
onPointerDown: (e) => _pointer = e.localPosition,
|
||||
onPointerHover: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerDown: (e) {
|
||||
_pointer = e.localPosition;
|
||||
coord.markInput();
|
||||
},
|
||||
onPointerSignal: (_) => coord.markInput(),
|
||||
child: _dropTarget(shell),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
@@ -17,9 +18,12 @@ class AvatarInfo {
|
||||
final String name;
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
final bool ready, buffering, relaying, host, self;
|
||||
/// Where they stand: joining (a little faded), away (dimmed with a moon),
|
||||
/// gone (dimmed, grey ring: reconnecting).
|
||||
final Presence presence;
|
||||
/// Since when they are gone; null otherwise.
|
||||
final DateTime? goneSince;
|
||||
/// Why their player cannot open the media (`members[].error`): an error
|
||||
/// mark; empty when it can.
|
||||
final String error;
|
||||
@@ -34,13 +38,19 @@ class AvatarInfo {
|
||||
this.ready = false,
|
||||
this.buffering = false,
|
||||
this.relaying = false,
|
||||
this.stale = false,
|
||||
this.presence = Presence.watching,
|
||||
this.goneSince,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
this.error = '',
|
||||
this.delivery = '',
|
||||
});
|
||||
|
||||
/// Sitting out.
|
||||
bool get away => presence == Presence.away;
|
||||
|
||||
/// Disconnected, in their grace period.
|
||||
bool get gone => presence == Presence.gone;
|
||||
}
|
||||
|
||||
/// Stable colour for someone else (colours are not on the wire).
|
||||
@@ -87,7 +97,8 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false, String error = ''}) {
|
||||
AvatarInfo selfAvatarInfo(
|
||||
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -98,7 +109,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = fal
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
presence: presence,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
@@ -115,14 +126,14 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && m.error.isEmpty && playing && m.buffered < r!.bufferNeed,
|
||||
buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
presence: m.presence,
|
||||
goneSince: m.goneSince,
|
||||
// The state marks the host (`members[].host`); your own app also
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
error: m.error,
|
||||
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
||||
);
|
||||
@@ -150,8 +161,9 @@ class PartyWatch extends StatelessWidget {
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out, red "!" = cannot play the media.
|
||||
/// tower = passing the video on, spinner = buffering, a little faded =
|
||||
/// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
|
||||
/// moon = sitting out, red "!" = cannot play the media.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -168,7 +180,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
final ringColor = !ring
|
||||
? bg
|
||||
: info.stale
|
||||
: info.gone
|
||||
? Colors.grey.shade700
|
||||
: info.buffering
|
||||
? Colors.amber
|
||||
@@ -195,7 +207,11 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
opacity: info.gone || info.away
|
||||
? 0.4
|
||||
: info.presence == Presence.joining
|
||||
? 0.7
|
||||
: 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
@@ -224,7 +240,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
if (info.away)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
@@ -281,7 +297,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 3 : m.ready ? 1 : 2);
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
@@ -356,13 +372,16 @@ class Pill extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Someone by display name (feed lines carry names, not ids): from the
|
||||
/// latest state when still there, else their initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
||||
/// Someone in a feed line or toast: by member [id] when it has one, else
|
||||
/// by display [name]; from the latest state when still there, else their
|
||||
/// initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
for (final m in r?.members ?? const <MemberView>[]) {
|
||||
if (m.name == name) return memberAvatarInfo(m, r, c);
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
// By id when the line has one (names change and repeat), else by name.
|
||||
for (final m in members) {
|
||||
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
|
||||
}
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -261,7 +261,7 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
case 'join':
|
||||
return (Icons.login, Colors.green, 'joined');
|
||||
case 'leave':
|
||||
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
||||
final reason = f.reason == 'grace' ? ' · lost signal too long' : '';
|
||||
return (Icons.logout, Colors.grey, 'left$reason');
|
||||
case 'ready':
|
||||
return (Icons.check_circle, Colors.green, 'is ready');
|
||||
@@ -279,12 +279,15 @@ Future<void> _messageMenu(BuildContext context, String text, String? link) async
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
case 'gone':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'out':
|
||||
case 'return':
|
||||
return (Icons.cloud_done, Colors.green, 'is back');
|
||||
case 'away':
|
||||
final who = f.target;
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
final why = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.bedtime, Colors.blueGrey, '${who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'}$why');
|
||||
case 'in':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
case 'queue-add':
|
||||
return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next');
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
|
||||
import '../session/delivery.dart';
|
||||
import '../session/party.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/app_menu.dart';
|
||||
@@ -282,8 +283,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
final ready = members.where((m) => m.ready).length;
|
||||
// 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;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -294,7 +298,11 @@ class _ServerLabel extends StatelessWidget {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null ? 'connecting…' : 'ready $ready/${members.length}',
|
||||
r == null
|
||||
? 'connecting…'
|
||||
: stale
|
||||
? 'reconnecting…'
|
||||
: 'ready $ready/${counted.length}',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
@@ -586,10 +594,13 @@ class _MemberTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
final since = m.goneSince;
|
||||
final status = m.presence == Presence.gone
|
||||
? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
|
||||
: m.presence == Presence.away
|
||||
? 'sitting out'
|
||||
: m.presence == Presence.joining
|
||||
? 'joining…'
|
||||
: m.error.isNotEmpty
|
||||
? "can't play this: ${m.error}"
|
||||
: info.buffering
|
||||
@@ -628,11 +639,11 @@ class _MemberTile extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
items: () => [
|
||||
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
|
||||
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
|
||||
if (info.self && m.out)
|
||||
if (info.self && m.presence == Presence.away)
|
||||
const AppMenuItem(value: 'back', label: 'Join back')
|
||||
else if (!m.out)
|
||||
else if (m.presence != Presence.away)
|
||||
const AppMenuItem(value: 'out', label: 'Sit out'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -61,6 +62,7 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
bool unstable = false,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
@@ -70,7 +72,11 @@ StatusView? playerStatusFor({
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
}
|
||||
if (playerError.isNotEmpty && state?.media != null && !(self?.out ?? false)) {
|
||||
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");
|
||||
}
|
||||
@@ -84,7 +90,7 @@ StatusView? playerStatusFor({
|
||||
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 (self != null && self.out) return null;
|
||||
if (away) return null;
|
||||
if (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
@@ -92,7 +98,7 @@ StatusView? playerStatusFor({
|
||||
// them; say why instead of "waiting for".
|
||||
final failed = [
|
||||
for (final m in r.members)
|
||||
if (m.error.isNotEmpty && !m.out && (self == null || m.id != self.id)) m,
|
||||
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.
|
||||
@@ -119,8 +125,8 @@ StatusView? playerStatusFor({
|
||||
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 are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out && m.error.isEmpty) m];
|
||||
// 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,
|
||||
@@ -137,6 +143,7 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
unstable: c.unstable.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
|
||||
@@ -6,9 +6,10 @@ import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show Presence;
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/identity.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -80,8 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
final hosting = online && !session.solo.value && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final out = self?.out ?? false;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||
final out = self?.presence == Presence.away;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, presence: self?.presence ?? Presence.watching);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -302,7 +302,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
if (_coord.selfAway.value) _sitOut(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 '../session/auto_sit_out.dart' show AutoSitOutTimes;
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sfx/sound_bank.dart';
|
||||
import '../state/session_state.dart';
|
||||
@@ -100,15 +101,17 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'When idle',
|
||||
title: 'Away',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('idle-sits-out'),
|
||||
key: const ValueKey('auto-sit-out'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('When idle, sit out instead of just unready'),
|
||||
subtitle: const Text('After 10 min away while ready and held, your player unloads the video until you join back (Space)'),
|
||||
value: AppSettings.instance.idleSitsOut.value,
|
||||
onChanged: AppSettings.instance.setIdleSitsOut,
|
||||
title: const Text('Auto sit-out'),
|
||||
subtitle: Text('Idle for ${AutoSitOutTimes.idle.inMinutes} min while nothing plays, or minimised '
|
||||
'for ${AutoSitOutTimes.minimised.inMinutes} min, sits you out so nobody waits for you; '
|
||||
"you're back as soon as you return. Never when you're alone"),
|
||||
value: AppSettings.instance.autoSitOut.value,
|
||||
onChanged: AppSettings.instance.setAutoSitOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -778,7 +781,7 @@ 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, the idle warning'),
|
||||
line('App notices', 'Shader changes, auto sit-out setting you away'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -80,7 +80,7 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
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));
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user