From 81d16183b7089108795a9692de98aa5ef80c7482 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:34:00 +0200 Subject: [PATCH] chore(orbit): show host-offline badge when host state goes stale Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitQueueHead.tsx | 38 +++++++++++++++++++++++++++++-- src/locales/de.ts | 1 + src/locales/en.ts | 1 + src/styles/components.css | 28 ++++++++++++++++++++++- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/components/OrbitQueueHead.tsx b/src/components/OrbitQueueHead.tsx index 21530636..527f24aa 100644 --- a/src/components/OrbitQueueHead.tsx +++ b/src/components/OrbitQueueHead.tsx @@ -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'; interface Props { 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 * and guest. Shows the session name and a comma-separated list of every * 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) { + 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 hostAway = role === 'guest' + && state.positionAt > 0 + && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS; + return (
-

{state.name}

+
+

{state.name}

+ {hostAway && ( + + + {t('orbit.hostAway')} + + )} +
{names.join(', ')} diff --git a/src/locales/de.ts b/src/locales/de.ts index a1bb9f77..6dd86142 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1484,6 +1484,7 @@ export const deTranslation = { shuffleLabel: 'Warteschlange wird neu gemischt in', catchUpLabel: 'aufholen', catchUpTooltip: 'Zur aktuellen Host-Position springen', + hostAway: 'Host offline', endTooltip: 'Session beenden', leaveTooltip: 'Session verlassen', participantsInviteLabel: 'Einladungslink', diff --git a/src/locales/en.ts b/src/locales/en.ts index 1c60877e..f829e721 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1487,6 +1487,7 @@ export const enTranslation = { shuffleLabel: 'Queue reshuffles in', catchUpLabel: 'catch up', catchUpTooltip: "Jump to the host's current position", + hostAway: 'Host offline', endTooltip: 'End session', leaveTooltip: 'Leave session', participantsInviteLabel: 'Invite link', diff --git a/src/styles/components.css b/src/styles/components.css index 2cc8cae6..abae11ea 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -12272,14 +12272,40 @@ html[data-psy-native-hidden="true"] *::after { border-bottom: 1px solid var(--border-subtle); 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 { - margin: 0 0 4px; + margin: 0; font-size: 15px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; 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 { display: flex;