diff --git a/src/App.tsx b/src/App.tsx index 43b989be..a1c685e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,9 @@ import GenreDetail from './pages/GenreDetail'; import ExportPickerModal from './components/ExportPickerModal'; import AppUpdater from './components/AppUpdater'; import TitleBar from './components/TitleBar'; +import OrbitSessionBar from './components/OrbitSessionBar'; +import { useOrbitHost } from './hooks/useOrbitHost'; +import { useOrbitGuest } from './hooks/useOrbitGuest'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; @@ -129,6 +132,10 @@ function AppShell() { const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); const [isTilingWm, setIsTilingWm] = useState(false); + // Orbit session hooks: idle until the local store marks a role. + useOrbitHost(); + useOrbitGuest(); + useEffect(() => { if (!IS_LINUX) return; invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); @@ -407,6 +414,7 @@ function AppShell() { onContextMenu={e => e.preventDefault()} > {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && } + {!isMobile && ( s.state); + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Second-level tick just for the shuffle countdown + drift readout — + // the store itself only ticks at 2.5 s which is too coarse for a smooth + // countdown. + useEffect(() => { + if (!state || phase !== 'active') return; + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [state, phase]); + + if (!state || (phase !== 'active' && phase !== 'ended')) return null; + + const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); + + // Guest-only: detect drift from the host's estimated live position. + const guestPlayback = usePlayerStore.getState(); + const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000); + const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId + ? computeOrbitDriftMs(state, localPositionMs, nowMs) + : null; + const showCatchUp = role === 'guest' + && state.isPlaying + && state.currentTrack + && (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS); + + const onExit = async () => { + try { + if (role === 'host') await endOrbitSession(); + else if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + const onCatchUp = async () => { + if (!state.currentTrack) return; + const trackId = state.currentTrack.trackId; + const targetMs = estimateLivePosition(state, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + try { + const song = await getSong(trackId); + if (!song) return; + const track = songToTrack(song); + const player = usePlayerStore.getState(); + if (player.currentTrack?.id === trackId) { + // Same track: just seek + resume. + player.seek(targetSec / Math.max(1, track.duration)); + if (!player.isPlaying) player.resume(); + } 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)); + } + }, 400); + } + } catch { + // silent — if the track is gone from the host's library, nothing we can do. + } + }; + + const participantCount = state.participants.length + 1; // +1 for the host + + return ( +
+
+
+ +
+ + 🔀 {formatCountdown(untilShuffle)} + +
+ +
+ {showCatchUp && ( + + )} + +
+
+ ); +} diff --git a/src/styles/components.css b/src/styles/components.css index fd2b0dcd..190d6fa0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11180,3 +11180,142 @@ html[data-app-hidden="true"] *::after { .np-dash-stats-values { gap: 16px; } .np-dash-stat-value { font-size: 18px; } } + +/* ───────────────────────────────────────────────────────────────────── + Orbit — session top strip + ───────────────────────────────────────────────────────────────────── */ + +.orbit-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 500; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 6px 14px; + background: color-mix(in srgb, var(--ctp-base, #1e1e2e) 80%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border, rgba(255,255,255,0.08))); + color: var(--text-primary); + font-size: 12px; + letter-spacing: 0.01em; + animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes orbit-bar-in { + from { opacity: 0; transform: translateY(-100%); } + to { opacity: 1; transform: translateY(0); } +} + +.orbit-bar__left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.orbit-bar__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent); + animation: orbit-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes orbit-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +.orbit-bar__name { + font-weight: 600; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; +} + +.orbit-bar__sep { + color: rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.orbit-bar__count, +.orbit-bar__host { + color: var(--text-muted); + flex-shrink: 0; +} + +.orbit-bar__center { + display: flex; + align-items: center; + justify-content: center; +} + +.orbit-bar__shuffle { + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-weight: 500; + padding: 3px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.orbit-bar__right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.orbit-bar__catchup { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + 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; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 120ms ease; +} +.orbit-bar__catchup:hover { + background: color-mix(in srgb, var(--accent) 26%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + transform: translateY(-1px); +} + +.orbit-bar__exit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-muted); + cursor: pointer; + transition: color 150ms ease, background 150ms ease, transform 180ms ease; +} +.orbit-bar__exit:hover { + color: var(--ctp-red, #f38ba8); + background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent); + transform: rotate(90deg); +} + +/* Push the rest of the shell down while the bar is visible. */ +.app-shell:has(.orbit-bar) { padding-top: 32px; }