exp(orbit): polish pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 17:23:28 +02:00
parent 11dddf6290
commit c7af6a6e15
3 changed files with 119 additions and 20 deletions
+8 -7
View File
@@ -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 {
+84 -3
View File
@@ -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();
+27 -10
View File
@@ -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;