Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back. Out members stay in chat and the party, never block or count, and load no media; a card with the live position and Join back (Space) replaces the video. The mini player's x sits you out; optionally idle does too. Nudge: the server forwards it to the target only (3/s, 20/min per sender); the target hears the nudge sound, gets a toast and a taskbar attention request. Kick, its messages, errors and dialog are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbfd351ee4
commit
8b81da4069
46 files changed
+855
-599
No files matched your search
@@ -112,9 +112,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
/// The next media to come up opens full (just joined, or put up here).
|
||||
bool _autoFull = false;
|
||||
int _lastMediaId = -1;
|
||||
double? _mutedVolume;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
final _stops = <void Function()>[];
|
||||
// Rebuild the shell on these flips, not on every state broadcast.
|
||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||
@@ -140,7 +138,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKicked;
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
@@ -181,17 +178,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (!_chatOpen) setState(() => _unread += chats);
|
||||
}));
|
||||
}));
|
||||
// Kicked: back offline, and say so.
|
||||
// Back from sitting out with the player closed: show it (the media
|
||||
// plays again).
|
||||
var wasOut = false;
|
||||
_stops.add(effect(() {
|
||||
final kick = coord.kickReason.value;
|
||||
if (kick == null) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
final out = coord.selfOut.value;
|
||||
if (wasOut && !out && session.route.peek() == AppRoute.session) {
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
|
||||
});
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
});
|
||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
||||
wasOut = out;
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
@@ -242,7 +239,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
_mutedVolume = null;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
@@ -258,7 +254,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
Future<void> _show(PlayerMode m) async {
|
||||
if (m == _mode) return;
|
||||
final seq = ++_modeSeq;
|
||||
if (m != PlayerMode.none) _unmute();
|
||||
setState(() {
|
||||
_from = _shown;
|
||||
_mode = m;
|
||||
@@ -282,27 +277,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
_show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
||||
/// The player's ×: sit out (stay in the server, nothing loaded; the
|
||||
/// player shows the join-back card). Once out, × closes the player.
|
||||
void _closePlayer() {
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.alive.peek()) {
|
||||
try {
|
||||
_mutedVolume = playback.player.state.volume;
|
||||
playback.player.setVolume(0);
|
||||
} catch (_) {}
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (coord.selfOut.peek()) {
|
||||
_show(PlayerMode.none);
|
||||
} else {
|
||||
coord.sitOut();
|
||||
}
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
void _unmute() {
|
||||
final v = _mutedVolume;
|
||||
if (v == null) return;
|
||||
_mutedVolume = null;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
playback.player.setVolume(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
@@ -520,12 +503,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
|
||||
if (ok == true) await SessionState.instance.leave();
|
||||
}
|
||||
|
||||
Future<void> _showKicked(String reason) async {
|
||||
if (reason == _lastKickShown) return;
|
||||
_lastKickShown = reason;
|
||||
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
|
||||
}
|
||||
|
||||
Future<void> _alert(String title, String text) async {
|
||||
if (!mounted || _dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
|
||||
@@ -17,6 +17,8 @@ class AvatarInfo {
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
/// Sitting out: dimmed, with an "away" mark.
|
||||
final bool out;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
@@ -28,6 +30,7 @@ class AvatarInfo {
|
||||
this.stale = false,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
this.out = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +78,7 @@ ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false, bool out = false}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
@@ -86,6 +89,7 @@ AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
out: out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +111,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
|
||||
// knows before the first state.
|
||||
host: m.host || (self && coord.hostFile != null),
|
||||
self: self,
|
||||
out: m.out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +137,8 @@ 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.
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale,
|
||||
/// dimmed with a moon = sitting out.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
@@ -176,7 +182,7 @@ class StatusAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale ? 0.4 : 1,
|
||||
opacity: info.stale || info.out ? 0.4 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
@@ -205,6 +211,17 @@ class StatusAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.out)
|
||||
Positioned(
|
||||
key: const ValueKey('away-mark'),
|
||||
left: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
@@ -240,7 +257,7 @@ class _AvatarStackState extends State<AvatarStack> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.out ? 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;
|
||||
|
||||
@@ -232,9 +232,11 @@ class ActivityRow extends StatelessWidget {
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'kick':
|
||||
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
||||
case 'out':
|
||||
final who = f.target;
|
||||
return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out');
|
||||
case 'back':
|
||||
return (Icons.login, Colors.green, 'joined back');
|
||||
default:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
@@ -337,7 +336,8 @@ class StripTab extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||
/// per member force ready and kick, and the address to invite friends.
|
||||
/// per member force ready, nudge and sit out, and the address to invite
|
||||
/// friends.
|
||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.7, -0.84),
|
||||
@@ -469,6 +469,8 @@ class _MemberTile extends StatelessWidget {
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: m.out
|
||||
? 'sitting out'
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
@@ -494,27 +496,30 @@ class _MemberTile extends StatelessWidget {
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == 'force') {
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
} else if (v == 'kick') {
|
||||
final ok = await showKickConfirmDialog(context, m.name);
|
||||
if (ok == true) coord.kick(m.id);
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
case 'force':
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
case 'nudge':
|
||||
coord.nudgeMember(m.id);
|
||||
case 'out':
|
||||
coord.sitOut(m.id);
|
||||
case 'back':
|
||||
coord.joinBack();
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self && !info.host)
|
||||
if (!m.out)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'Kick',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self)
|
||||
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
|
||||
if (info.self && m.out)
|
||||
const PopupMenuItem(value: 'back', child: Text('Join back'))
|
||||
else if (!m.out)
|
||||
const PopupMenuItem(value: 'out', child: Text('Sit out')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What the player's status pill says, most pressing first.
|
||||
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
enum StatusKind { reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||
|
||||
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||
/// (the whole pill).
|
||||
@@ -59,16 +59,10 @@ String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1
|
||||
StatusView? playerStatusFor({
|
||||
required HealthStatus health,
|
||||
bool reconnecting = false,
|
||||
String? kickReason,
|
||||
StateBroadcast? state,
|
||||
MemberView? self,
|
||||
bool slowConnect = false,
|
||||
}) {
|
||||
if (kickReason != null) {
|
||||
return StatusView(StatusKind.kicked,
|
||||
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
|
||||
tone: HealthSeverity.error, short: 'Removed from the server');
|
||||
}
|
||||
if (reconnecting) {
|
||||
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||
@@ -82,6 +76,8 @@ 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 (r.transport == 'finished') {
|
||||
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||
}
|
||||
@@ -100,7 +96,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');
|
||||
}
|
||||
final active = [for (final m in r.members) if (!m.stale) m];
|
||||
// Members sitting out are never waited for nor counted.
|
||||
final active = [for (final m in r.members) if (!m.stale && !m.out) m];
|
||||
final others = [
|
||||
for (final m in active)
|
||||
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||
@@ -117,7 +114,6 @@ StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||
return playerStatusFor(
|
||||
health: c.health.value,
|
||||
reconnecting: c.reconnecting.value,
|
||||
kickReason: c.kickReason.value,
|
||||
state: c.serverState.value,
|
||||
self: c.selfMember.value,
|
||||
slowConnect: slowConnect,
|
||||
@@ -258,7 +254,6 @@ class _StatusLine extends StatelessWidget {
|
||||
Widget? _lead() {
|
||||
final v = view;
|
||||
switch (v.kind) {
|
||||
case StatusKind.kicked:
|
||||
case StatusKind.problem:
|
||||
final error = v.tone == HealthSeverity.error;
|
||||
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||
|
||||
@@ -81,7 +81,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
final hosting = online && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
|
||||
final out = self?.out ?? false;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting, out: out);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -166,7 +167,8 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
Pill(online ? 'in a server' : 'offline',
|
||||
online ? Colors.greenAccent : Colors.grey),
|
||||
if (online)
|
||||
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
|
||||
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
|
||||
Colors.lightBlueAccent),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -237,6 +239,16 @@ class _ProfileCardState extends State<ProfileCard> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(children: [
|
||||
if (self != null)
|
||||
_link(
|
||||
out ? Icons.login_rounded : Icons.event_seat_outlined,
|
||||
out ? 'Join back' : 'Sit out',
|
||||
trailing: Text(out ? 'Space' : 'stay in the server', style: small),
|
||||
onTap: () {
|
||||
out ? coord.joinBack() : coord.sitOut();
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
_link(
|
||||
Icons.extension_outlined,
|
||||
'Jellyfin & integrations',
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
import 'sit_out_card.dart';
|
||||
import 'start_banner.dart';
|
||||
|
||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||
@@ -298,6 +299,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (widget.fullscreen) ...[_pausedParty(), _unreadChip()],
|
||||
] else
|
||||
_miniOverlay(),
|
||||
// Sitting out: nothing is loaded; the card covers the
|
||||
// empty player (which stays mounted for joining back).
|
||||
if (_coord.selfOut.value) _sitOut(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -784,6 +788,38 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
),
|
||||
);
|
||||
|
||||
/// [SitOutCard] with the mini's (or the full player's) expand /
|
||||
/// collapse and close.
|
||||
Widget _sitOut() => Positioned.fill(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: _full ? widget.onCollapse : widget.onExpand,
|
||||
tooltip: _full ? 'Small player' : 'Expand',
|
||||
icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Close the player',
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Mini: title, expand and close on hover; ready, time and a seekable
|
||||
/// bar at the bottom. A thin progress line stays while not hovered.
|
||||
Widget _miniOverlay() {
|
||||
@@ -823,7 +859,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
IconButton(
|
||||
focusNode: _noFocus,
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Stop watching (stay in the server)',
|
||||
tooltip: 'Sit out (stay in the server)',
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -97,6 +97,19 @@ class SettingsTab extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'When idle',
|
||||
child: SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
key: const ValueKey('idle-sits-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,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
case SettingsSection.player:
|
||||
return [
|
||||
@@ -510,8 +523,9 @@ class NotificationsInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves, kicks, video changes and chat',
|
||||
line('Joins, leaves, video changes and chat',
|
||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||
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, a frozen video reloaded, the idle warning'),
|
||||
],
|
||||
@@ -617,10 +631,15 @@ class SoundSettings extends StatelessWidget {
|
||||
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
|
||||
],
|
||||
),
|
||||
Switch(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
// A nudge always plays (an explicit poke): no switch,
|
||||
// its place kept so the rows line up.
|
||||
Visibility.maintain(
|
||||
visible: kind != SfxKind.nudge,
|
||||
child: Switch(
|
||||
key: ValueKey('sound-on-${kind.name}'),
|
||||
value: !off.contains(kind.name),
|
||||
onChanged: volume == 0 || kind == SfxKind.nudge ? null : (on) => s.setSoundOn(kind.name, on),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../playback/media_marks.dart' show formatClock;
|
||||
import '../state/session_state.dart';
|
||||
import '../sync/state_diff.dart' show serverPositionAt;
|
||||
import 'now_playing_title.dart';
|
||||
import 'start_banner.dart';
|
||||
|
||||
/// In place of the video while self sits out: the media's start card
|
||||
/// (cover, title, episode), where the others are ("12:34 / 24:00",
|
||||
/// playing or paused) and "Join back" (or Space). [compact]: the mini
|
||||
/// player.
|
||||
class SitOutCard extends StatefulWidget {
|
||||
final bool compact;
|
||||
const SitOutCard({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
State<SitOutCard> createState() => _SitOutCardState();
|
||||
}
|
||||
|
||||
class _SitOutCardState extends State<SitOutCard> {
|
||||
// The position moves on while playing: redraw each second.
|
||||
late final Timer _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final small = widget.compact;
|
||||
return ListenableBuilder(
|
||||
listenable: artChanges(),
|
||||
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||
final r = coord.serverState.value;
|
||||
final now = coord.nowPlaying.value;
|
||||
final playing = r?.transport == 'playing';
|
||||
final duration = (r?.media?['duration'] as num?)?.toDouble() ?? 0;
|
||||
final at = r == null ? 0.0 : serverPositionAt(r, coord.sync.serverNow());
|
||||
Duration secs(double s) => Duration(milliseconds: (s * 1000).round());
|
||||
final where = r?.media == null
|
||||
? 'Nothing is up'
|
||||
: duration > 0
|
||||
? '${formatClock(secs(at.clamp(0, duration)))} / ${formatClock(secs(duration))}'
|
||||
: formatClock(secs(at));
|
||||
final text = TextStyle(color: Colors.white, fontSize: small ? 12 : 15);
|
||||
final panel = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("You're sitting out", style: text.copyWith(fontWeight: FontWeight.w700, fontSize: small ? 13 : 20)),
|
||||
SizedBox(height: small ? 4 : 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (r?.media != null) ...[
|
||||
Icon(playing ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
||||
color: Colors.white70, size: small ? 14 : 18),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(where, key: const ValueKey('sit-out-position'), style: text.copyWith(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: small ? 8 : 16),
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('join-back'),
|
||||
onPressed: coord.joinBack,
|
||||
icon: Icon(Icons.login_rounded, size: small ? 16 : 20),
|
||||
label: Text(small ? 'Join back' : 'Join back (Space)'),
|
||||
style: small
|
||||
? FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (now != null) StartCard(now: now) else const ColoredBox(color: Color(0xFF0C0D11)),
|
||||
// Small: the card's title sits at the bottom, so the panel goes up top.
|
||||
Align(
|
||||
alignment: small ? const Alignment(0, -0.35) : Alignment.center,
|
||||
child: panel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
: 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user