diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index e279deef..5ff60311 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -108,24 +108,25 @@ export default function OrbitSessionBar() { const trackId = state.currentTrack.trackId; const targetMs = estimateLivePosition(state, Date.now()); const targetSec = Math.max(0, targetMs / 1000); + const hostPlaying = state.isPlaying; try { const song = await getSong(trackId); if (!song) return; const track = songToTrack(song); const player = usePlayerStore.getState(); + const fraction = targetSec / Math.max(1, track.duration); if (player.currentTrack?.id === trackId) { - // Same track: just seek + resume. - player.seek(targetSec / Math.max(1, track.duration)); - if (!player.isPlaying) player.resume(); + player.seek(fraction); + if (hostPlaying && !player.isPlaying) player.resume(); + else if (!hostPlaying && player.isPlaying) player.pause(); } else { // Different track: play + seek on next tick once engine is ready. player.playTrack(track, [track]); - // Best-effort: seek to the host's position a beat later. window.setTimeout(() => { const p = usePlayerStore.getState(); - if (p.currentTrack?.id === trackId) { - p.seek(targetSec / Math.max(1, track.duration)); - } + if (p.currentTrack?.id !== trackId) return; + p.seek(fraction); + if (!hostPlaying && p.isPlaying) p.pause(); }, 400); } } catch { diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index e51174c5..82af6da3 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -1,12 +1,14 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { getSong } from '../api/subsonic'; import { readOrbitState, writeOrbitHeartbeat, leaveOrbitSession, } from '../utils/orbit'; -import { orbitOutboxPlaylistName } from '../api/orbit'; +import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit'; /** * Orbit — guest-side tick hook. @@ -37,11 +39,54 @@ export function useOrbitGuest(): void { const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId; - // ── State read + end/kick detection ────────────────────────────────── + /** + * Last host playback state we *applied* to the local player. Compared + * against the new tick to detect host-side flips (track change / + * play-pause toggle) and against the local player's current state to + * detect guest-side divergence (the guest paused or skipped on their own). + * + * Reset to null on (re-)activation so a fresh session re-syncs from scratch. + */ + const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null); + + // ── State read + end/kick detection + auto-sync to host ────────────── useEffect(() => { if (!active || !sessionPlaylistId) return; let cancelled = false; + lastAppliedRef.current = null; + + /** + * Load `trackId` into the local player and seek to the host's live + * position. Mirrors the host's `isPlaying` (so a guest joining a paused + * host doesn't auto-start the music). Best-effort; silent on miss. + */ + const syncToHost = async (trackId: string, hostState: OrbitState) => { + try { + const song = await getSong(trackId); + if (!song || cancelled) return; + const track = songToTrack(song); + const targetMs = estimateLivePosition(hostState, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + const player = usePlayerStore.getState(); + const fraction = targetSec / Math.max(1, track.duration); + if (player.currentTrack?.id === trackId) { + player.seek(fraction); + if (hostState.isPlaying && !player.isPlaying) player.resume(); + else if (!hostState.isPlaying && player.isPlaying) player.pause(); + } else { + player.playTrack(track, [track]); + // Defer seek + state-match until the engine has actually loaded. + window.setTimeout(() => { + if (cancelled) return; + const p = usePlayerStore.getState(); + if (p.currentTrack?.id !== trackId) return; + p.seek(fraction); + if (!hostState.isPlaying && p.isPlaying) p.pause(); + }, 400); + } + } catch { /* silent */ } + }; const pull = async () => { const state = await readOrbitState(sessionPlaylistId); @@ -78,8 +123,44 @@ export function useOrbitGuest(): void { const hit = state.removed.find(r => r.user === me && r.at > joinedAt); if (hit) { useOrbitStore.getState().setError('removed'); + return; } } + + // ── Auto-sync host playback into local player ── + // Rules: + // 1. First tick after activation → mirror host (initial join sync, + // no need for the guest to click catch-up to get started). + // 2. Track changed at host → guest follows. Track-change is the + // "session sync point"; it overrides any local divergence. + // 3. Same track, host flipped play/pause → mirror only if the local + // player still matches our last-applied host state. If the guest + // paused/resumed locally, we leave them alone — they have to + // click catch-up to opt back in. + const player = usePlayerStore.getState(); + const hostTrackId = state.currentTrack?.trackId ?? null; + const hostPlaying = state.isPlaying; + const last = lastAppliedRef.current; + + if (!last) { + if (hostTrackId) void syncToHost(hostTrackId, state); + lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } else if (last.trackId !== hostTrackId) { + if (hostTrackId) void syncToHost(hostTrackId, state); + else if (player.isPlaying) player.pause(); + lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } else if (last.isPlaying !== hostPlaying) { + // Only mirror when the guest hasn't diverged. We compare against the + // *last applied* host state, not the new one — divergence means the + // local player no longer matches what we last pushed in. + if (player.isPlaying === last.isPlaying) { + if (hostPlaying) player.resume(); + else player.pause(); + } + // Either way, advance the anchor so we don't keep retrying the same + // flip every tick. + lastAppliedRef.current = { trackId: last.trackId, isPlaying: hostPlaying }; + } }; void pull(); diff --git a/src/styles/components.css b/src/styles/components.css index 464ce889..1debdb87 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11290,23 +11290,40 @@ html[data-app-hidden="true"] *::after { .orbit-bar__catchup { display: inline-flex; align-items: center; - gap: 5px; - padding: 4px 10px; + gap: 6px; + padding: 6px 14px; border-radius: 999px; - background: color-mix(in srgb, var(--accent) 16%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent); - color: var(--accent); - font-size: 11px; - font-weight: 600; + background: var(--accent); + border: 1px solid var(--accent); + color: #fff; + font-size: 12px; + font-weight: 700; letter-spacing: 0.02em; cursor: pointer; - transition: background 150ms ease, border-color 150ms ease, transform 120ms ease; + transition: filter 150ms ease, transform 120ms ease, box-shadow 150ms ease; + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); + animation: orbit-catchup-pulse 1.8s ease-in-out infinite; } .orbit-bar__catchup:hover { - background: color-mix(in srgb, var(--accent) 26%, transparent); - border-color: color-mix(in srgb, var(--accent) 55%, transparent); + filter: brightness(1.08); transform: translateY(-1px); } +.orbit-bar__catchup svg { + animation: orbit-catchup-spin 2.4s linear infinite; +} + +@keyframes orbit-catchup-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); + } +} +@keyframes orbit-catchup-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} .orbit-bar__settings { display: inline-flex;