chore(orbit): show host-offline badge when host state goes stale

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 16:34:00 +02:00
parent 7acf95c0a6
commit 81d16183b7
4 changed files with 65 additions and 3 deletions
+36 -2
View File
@@ -1,20 +1,54 @@
import { Users } from 'lucide-react'; import { useEffect, useState } from 'react';
import { Users, WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import type { OrbitState } from '../api/orbit'; import type { OrbitState } from '../api/orbit';
interface Props { interface Props {
state: OrbitState; state: OrbitState;
} }
/** Host's state hasn't updated for this long → guest treats them as offline. */
const HOST_AWAY_THRESHOLD_MS = 15_000;
/** /**
* Shared Orbit head strip rendered at the top of the queue for both host * Shared Orbit head strip rendered at the top of the queue for both host
* and guest. Shows the session name and a comma-separated list of every * and guest. Shows the session name and a comma-separated list of every
* participant (host first, then guests in join order). * participant (host first, then guests in join order).
*
* Guest view additionally surfaces host-presence: when the host's tick
* hasn't been seen for 15 s we render a subtle "host offline" badge so
* the guest knows the stalled playback isn't a local problem.
*/ */
export default function OrbitQueueHead({ state }: Props) { export default function OrbitQueueHead({ state }: Props) {
const { t } = useTranslation();
const role = useOrbitStore(s => s.role);
const [nowMs, setNowMs] = useState(() => Date.now());
// Guest-only clock tick — React wouldn't re-render a stale state blob
// on its own, and the presence threshold is time-based.
useEffect(() => {
if (role !== 'guest') return;
const id = window.setInterval(() => setNowMs(Date.now()), 2000);
return () => window.clearInterval(id);
}, [role]);
const names = [state.host, ...state.participants.map(p => p.user)]; const names = [state.host, ...state.participants.map(p => p.user)];
const hostAway = role === 'guest'
&& state.positionAt > 0
&& (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS;
return ( return (
<div className="orbit-queue-head"> <div className="orbit-queue-head">
<h2 className="orbit-queue-head__title">{state.name}</h2> <div className="orbit-queue-head__title-row">
<h2 className="orbit-queue-head__title">{state.name}</h2>
{hostAway && (
<span className="orbit-queue-head__presence" role="status">
<WifiOff size={11} />
<span>{t('orbit.hostAway')}</span>
</span>
)}
</div>
<div className="orbit-queue-head__meta"> <div className="orbit-queue-head__meta">
<Users size={11} /> <Users size={11} />
<span className="orbit-queue-head__names">{names.join(', ')}</span> <span className="orbit-queue-head__names">{names.join(', ')}</span>
+1
View File
@@ -1484,6 +1484,7 @@ export const deTranslation = {
shuffleLabel: 'Warteschlange wird neu gemischt in', shuffleLabel: 'Warteschlange wird neu gemischt in',
catchUpLabel: 'aufholen', catchUpLabel: 'aufholen',
catchUpTooltip: 'Zur aktuellen Host-Position springen', catchUpTooltip: 'Zur aktuellen Host-Position springen',
hostAway: 'Host offline',
endTooltip: 'Session beenden', endTooltip: 'Session beenden',
leaveTooltip: 'Session verlassen', leaveTooltip: 'Session verlassen',
participantsInviteLabel: 'Einladungslink', participantsInviteLabel: 'Einladungslink',
+1
View File
@@ -1487,6 +1487,7 @@ export const enTranslation = {
shuffleLabel: 'Queue reshuffles in', shuffleLabel: 'Queue reshuffles in',
catchUpLabel: 'catch up', catchUpLabel: 'catch up',
catchUpTooltip: "Jump to the host's current position", catchUpTooltip: "Jump to the host's current position",
hostAway: 'Host offline',
endTooltip: 'End session', endTooltip: 'End session',
leaveTooltip: 'Leave session', leaveTooltip: 'Leave session',
participantsInviteLabel: 'Invite link', participantsInviteLabel: 'Invite link',
+27 -1
View File
@@ -12272,14 +12272,40 @@ html[data-psy-native-hidden="true"] *::after {
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0; flex-shrink: 0;
} }
.orbit-queue-head__title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
min-width: 0;
}
.orbit-queue-head__title { .orbit-queue-head__title {
margin: 0 0 4px; margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1;
min-width: 0;
}
.orbit-queue-head__presence {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ctp-yellow, #f9e2af);
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent);
border-radius: 999px;
}
.orbit-queue-head__presence svg {
color: var(--ctp-yellow, #f9e2af);
} }
.orbit-queue-head__meta { .orbit-queue-head__meta {
display: flex; display: flex;