mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
exp(orbit): polish pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user