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;