mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths
When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.
In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.
Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.
* fix(orbit): treat natural track-end as not-diverged in guest sync
When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.
`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.
Confirmed via the captured guest log buffer:
18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
(player.isPlaying=false ≠ last.isPlaying=true)
— guest stuck for ~33s until Catch Up was pressed.
* fix(orbit): Catch Up polls until engine is ready before seeking
The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).
Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.
* docs(changelog): add orbit guest playback fixes entry
* fix(orbit): debounce Catch Up button + match bar item height
Two follow-on UX fixes after PR #525's three primary bugs landed:
1. **Debounce visibility.** Drift is computed from an asymmetric signal:
guest's `currentTime` updates in coarse ~5 s chunks, while host's
position is extrapolated linearly via `(nowMs - posAt)`. Even on a
perfectly-synced session the diff swings ±5 s every tick, so the
button flickered in and out continuously. Show only after drift has
stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
noise is filtered out, real sustained drift still surfaces in time.
2. **Match neighbour height.** The button was 32 px tall against 26 px
for the other action buttons (.orbit-bar__settings) so every flicker
shifted the entire bar height. Set `height: 26 px` and tighten the
padding/font so the layout is stable regardless of visibility.
* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek
Two follow-on fixes after the 4-bug umbrella:
**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.
**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.
* fix(orbit): double-click play button + hide preview during session
Two cucadmuh-flagged gaps:
**1. Double-click on the inline play button now reaches the orbit-add
path.** The album-track row's onDoubleClick already routes to
`addTrackToOrbit` when in Orbit, but the inline play button stopped
propagation on click — so clicking it twice just fired the "double-
click to add" hint toast and never touched the orbit queue. Add an
onDoubleClick on the button itself that delegates to the parent's
`onDoubleClickSong`.
**2. Track preview is suppressed during an Orbit session.** Preview
shares the Rust audio engine with the shared playback, so starting
one as a guest yanks the host's track out from under everyone. A new
`[data-orbit-active]` attribute on `<html>` (set whenever role is
host/guest and phase is starting/joining/active) hides every
preview button via a single CSS rule, and `previewStore.startPreview`
short-circuits as a defensive guard for keyboard shortcuts and any
programmatic callers.
This commit is contained in:
committed by
GitHub
parent
a702a5dd5b
commit
af1b9661f5
@@ -221,6 +221,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Fixed
|
||||
|
||||
### Orbit — guest playback fixes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by nzxl + RavingGrob, PR [#525](https://github.com/Psychotoxical/psysonic/pull/525)**
|
||||
|
||||
* **All local queue-extension paths are suppressed for the entire Orbit session lifecycle**, not just while the session is `active`. Radio top-up, infinite-queue top-up, queue-exhaustion fallback, and the proactive "≤ 2 auto-tracks ahead" topper all refuse to extend the local queue while role is host or guest and phase is `starting` / `joining` / `active`. Even fetch promises that were already in flight re-check at resolution time, so a click-Join racing with a fetch can't pop the bulk-add modal *after* the join completes. Without this lockout the guest got a "Add 5 tracks to the Orbit queue?" prompt on track-end (offering to inject unrelated suggestions into the host's shared queue) and the local queue silently drifted off the host's playlist.
|
||||
* **Natural track-end no longer reads as "the guest manually paused"** — the divergence check in the guest pull tick now distinguishes the two via the player's `currentTime` (which `handleAudioEnded` resets to 0, while a real pause leaves it mid-track). Without the discriminator the guest sat silent on every host-driven track change that arrived in the 0–2.5 s window after the guest's own track had ended.
|
||||
* **Initial-sync and Catch Up both now wait for the audio engine to actually report playing before seeking** (up to 5 s on initial-sync, 4 s on Catch Up). The previous timeout-and-fire approach would drop a seek onto a not-yet-ready engine, where it silently no-oped — guest played from 0:00 while believing they were synced. The visible symptom was "I clicked Catch Up and the song jumped 50 % forward": the second click finally caught the engine ready, so the seek that should have happened on join finally landed.
|
||||
* **Catch Up button no longer flickers and no longer changes the bar height.** Drift is computed from a noisy signal (guest's `currentTime` updates in coarse chunks while host's position is extrapolated linearly), so the diff swings ±5 s on a normal session even when sync is fine — and the button popping in/out shifted the whole Orbit bar up and down because it was 6 px taller than the other action buttons. Visibility is now debounced (must stay over the threshold for ≥ 3 s before the button appears) and the button matches the 26 px height of its neighbours so the bar's vertical layout is stable.
|
||||
* **Double-clicking the inline play button on an album-track row now suggests/enqueues to the host's queue**, matching the existing double-click-on-row behaviour. Previously the button stopped propagation on click, so its double-click never reached the row's orbit-aware handler — clicking it twice just bounced the "double-click to add" hint toast.
|
||||
* **Track preview is now hidden + blocked during an Orbit session.** The preview path runs through the same Rust audio engine as the shared playback, so starting a preview as a guest would clobber the host's track in the local player. The preview button is hidden across all surfaces (album / artist / favourites / playlist / random-mix track lists) via a global `[data-orbit-active]` CSS rule, and `previewStore.startPreview` no-ops as a defensive guard for keyboard shortcuts and any programmatic callers.
|
||||
|
||||
### Context menu — render above the floating player bar
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Prymz, PR [#522](https://github.com/Psychotoxical/psysonic/pull/522)**
|
||||
|
||||
+16
@@ -186,6 +186,22 @@ function AppShell() {
|
||||
useOrbitHost();
|
||||
useOrbitGuest();
|
||||
|
||||
// Body-level marker so global CSS can hide controls that don't make sense
|
||||
// in an Orbit session (e.g. track preview — the preview engine and the
|
||||
// shared playback would step on each other). Active for any role + any
|
||||
// pre-`active` phase so the marker covers the whole join lifecycle.
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const orbitPhase = useOrbitStore(s => s.phase);
|
||||
useEffect(() => {
|
||||
const inOrbit = (orbitRole === 'host' || orbitRole === 'guest')
|
||||
&& (orbitPhase === 'active' || orbitPhase === 'joining' || orbitPhase === 'starting');
|
||||
if (inOrbit) {
|
||||
document.documentElement.setAttribute('data-orbit-active', 'true');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-orbit-active');
|
||||
}
|
||||
}, [orbitRole, orbitPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
|
||||
@@ -150,6 +150,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
|
||||
@@ -66,6 +66,36 @@ export default function OrbitSessionBar() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [state, phase]);
|
||||
|
||||
// ── Catch Up button visibility — debounced ────────────────────────────
|
||||
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
|
||||
// ~5 s chunks while host's position is extrapolated linearly via
|
||||
// `(nowMs - posAt)`, so the diff swings ±5 s every tick on a normal
|
||||
// session even when both sides are perfectly synced. Without debounce
|
||||
// the button flickered in and out and (worse) shifted the bar height.
|
||||
// Require ≥ 3 s of sustained over-threshold drift before showing.
|
||||
const [showCatchUp, setShowCatchUp] = useState(false);
|
||||
const overSinceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (role !== 'guest' || !state || !state.isPlaying || !state.currentTrack) {
|
||||
overSinceRef.current = null;
|
||||
setShowCatchUp(false);
|
||||
return;
|
||||
}
|
||||
const player = usePlayerStore.getState();
|
||||
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
|
||||
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
|
||||
? computeOrbitDriftMs(state, localPositionMs, nowMs)
|
||||
: null;
|
||||
const isOver = driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS;
|
||||
if (isOver) {
|
||||
if (overSinceRef.current === null) overSinceRef.current = Date.now();
|
||||
if (Date.now() - overSinceRef.current >= 3000) setShowCatchUp(true);
|
||||
} else {
|
||||
overSinceRef.current = null;
|
||||
setShowCatchUp(false);
|
||||
}
|
||||
}, [role, state, nowMs]);
|
||||
|
||||
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
|
||||
const shouldShowBar = !!state && (
|
||||
phase === 'active'
|
||||
@@ -78,17 +108,6 @@ export default function OrbitSessionBar() {
|
||||
|
||||
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - 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 performExit = async () => {
|
||||
try {
|
||||
if (role === 'host') await endOrbitSession();
|
||||
@@ -128,14 +147,25 @@ export default function OrbitSessionBar() {
|
||||
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.
|
||||
// Different track: play + seek once the engine reports the track
|
||||
// loaded. The previous 400 ms blind delay was too short for an
|
||||
// HTTP-streamed cold-start on a transcontinental link, so the seek
|
||||
// would silently no-op and playback started at 0:00 — making Catch
|
||||
// Up effectively useless on the very latency where it matters most.
|
||||
// Mirrors the poll-until-ready pattern used by `syncToHost`.
|
||||
player.playTrack(track, [track]);
|
||||
window.setTimeout(() => {
|
||||
const deadline = Date.now() + 4000;
|
||||
const poll = () => {
|
||||
const p = usePlayerStore.getState();
|
||||
if (p.currentTrack?.id !== trackId) return;
|
||||
p.seek(fraction);
|
||||
if (!hostPlaying && p.isPlaying) p.pause();
|
||||
}, 400);
|
||||
if (p.currentTrack?.id !== trackId) return; // user changed tracks
|
||||
if (p.isPlaying || Date.now() >= deadline) {
|
||||
p.seek(fraction);
|
||||
if (!hostPlaying && p.isPlaying) p.pause();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(poll, 100);
|
||||
};
|
||||
window.setTimeout(poll, 100);
|
||||
}
|
||||
} catch {
|
||||
// silent — if the track is gone from the host's library, nothing we can do.
|
||||
|
||||
@@ -103,16 +103,22 @@ export function useOrbitGuest(): void {
|
||||
|
||||
player.playTrack(track, [track]);
|
||||
|
||||
// Poll until the engine has the track loaded; fall back to a blind
|
||||
// apply after 2 s so a stuck load doesn't leave us spinning forever.
|
||||
// Poll until the engine actually reports the track playing — the
|
||||
// earlier "blind apply at deadline" path could fire a seek into a
|
||||
// not-yet-ready engine, where the seek silently no-ops and the
|
||||
// guest plays from 0 while believing they're synced (the visible
|
||||
// 50 % jump-on-Catch-Up symptom). Wait for `p.isPlaying === true`
|
||||
// up to 5 s, then give up and let the outer pull tick retry —
|
||||
// the syncToHost-failed path keeps `lastAppliedRef` null so the
|
||||
// 500 ms fast-poll in `tick` will try again immediately.
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const deadline = Date.now() + 2000;
|
||||
const deadline = Date.now() + 5000;
|
||||
const poll = () => {
|
||||
if (cancelled) { resolve(false); return; }
|
||||
const p = usePlayerStore.getState();
|
||||
const trackReady = p.currentTrack?.id === trackId;
|
||||
if (trackReady && p.isPlaying) { resolve(applyMirror()); return; }
|
||||
if (Date.now() >= deadline) { resolve(applyMirror()); return; }
|
||||
if (Date.now() >= deadline) { resolve(false); return; }
|
||||
window.setTimeout(poll, 100);
|
||||
};
|
||||
window.setTimeout(poll, 100);
|
||||
@@ -228,7 +234,17 @@ export function useOrbitGuest(): void {
|
||||
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
|
||||
}
|
||||
} else if (last.trackId !== hostTrackId) {
|
||||
const diverged = player.isPlaying !== last.isPlaying;
|
||||
// Distinguish "user manually paused" (true divergence) from "track
|
||||
// ended naturally" (NOT divergence — guest just needs the host's
|
||||
// next track loaded). Both leave `player.isPlaying === false`, but
|
||||
// `handleAudioEnded` keeps `currentTrack` pinned to the just-ended
|
||||
// track and resets `currentTime` to 0; a manual pause leaves
|
||||
// `currentTime` somewhere mid-track. The 0-position discriminator
|
||||
// separates them.
|
||||
const naturalEnd = !player.isPlaying
|
||||
&& player.currentTrack?.id === last.trackId
|
||||
&& (player.currentTime ?? 0) < 0.5;
|
||||
const diverged = !naturalEnd && player.isPlaying !== last.isPlaying;
|
||||
if (diverged) {
|
||||
// Guest is running their own show (typically: paused while host
|
||||
// kept going). Do not load/start the host's new track — just
|
||||
|
||||
@@ -412,6 +412,20 @@ let playGeneration = 0;
|
||||
let infiniteQueueFetching = false;
|
||||
// Guard against concurrent radio top-up fetches.
|
||||
let radioFetching = false;
|
||||
|
||||
/** True when the user is part of an Orbit session (any role, any phase
|
||||
* short of `idle` / `error` / `ended`). Used by `next()` and its async
|
||||
* fallback callbacks to suppress local queue-extension paths (radio
|
||||
* top-up, infinite-queue, queue-exhausted refill) — those would either
|
||||
* pop the orbitBulkGuard modal or silently inject tracks the host
|
||||
* didn't pick. The helper is also called inside in-flight `.then()`
|
||||
* callbacks so a fetch scheduled just before the user joined Orbit
|
||||
* doesn't fire a `playTrack` after the join. */
|
||||
function isInOrbitSession(): boolean {
|
||||
const o = useOrbitStore.getState();
|
||||
if (o.role !== 'host' && o.role !== 'guest') return false;
|
||||
return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting';
|
||||
}
|
||||
// Artist ID used to start the current radio session — persists across track
|
||||
// advances so proactive loading works even when songs lack artistId.
|
||||
let currentRadioArtistId: string | null = null;
|
||||
@@ -3013,13 +3027,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
get().playTrack(queue[nextIdx], queue, manual, false, nextIdx);
|
||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||
// so the queue never runs dry without a visible loading pause.
|
||||
// Skipped while in Orbit — the host's queue is the source of
|
||||
// truth there, and any silent local extension would either
|
||||
// drift this client off the host or pop the bulk-add modal at
|
||||
// the next track-end fallback.
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching) {
|
||||
if (infiniteQueueEnabled && repeatMode === 'off' && !infiniteQueueFetching && !isInOrbitSession()) {
|
||||
const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length;
|
||||
if (remainingAuto <= 2) {
|
||||
infiniteQueueFetching = true;
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
// Re-check at resolution time — the user may have joined
|
||||
// an Orbit session between scheduling and resolving.
|
||||
if (isInOrbitSession()) return;
|
||||
if (newTracks.length > 0) {
|
||||
set(state => ({ queue: [...state.queue, ...newTracks] }));
|
||||
}
|
||||
@@ -3076,6 +3097,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||
get().playTrack(queue[0], queue, manual, false, 0);
|
||||
} else {
|
||||
// ── Orbit short-circuit ──
|
||||
// The host owns the shared queue. The radio / infinite-queue
|
||||
// fallbacks below would either pop the orbitBulkGuard modal (with a
|
||||
// 6-track add) or silently inject unrelated tracks into the local
|
||||
// player and drift the guest off the host. Stop instead and let the
|
||||
// next pull tick in `useOrbitGuest` sync to the host's next track.
|
||||
// Covers any active orbit phase (`active` / `joining` / `starting`)
|
||||
// so a fetch scheduled mid-join doesn't slip through.
|
||||
if (isInOrbitSession()) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
// Queue exhausted. Check radio first (independent of infinite queue setting),
|
||||
// then infinite queue, then stop.
|
||||
if (currentTrack?.radioAdded && !radioFetching) {
|
||||
@@ -3085,6 +3120,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
|
||||
.then(([similar, top]) => {
|
||||
radioFetching = false;
|
||||
// The user may have joined an Orbit session while this
|
||||
// fetch was in flight — bail without touching the queue.
|
||||
if (isInOrbitSession()) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
// Same source preference + dedup contract as the proactive
|
||||
// top-up: similar first, top only as a fallback (issue #500).
|
||||
@@ -3123,6 +3166,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const existingIds = new Set(get().queue.map(t => t.id));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
infiniteQueueFetching = false;
|
||||
// The user may have joined an Orbit session while this
|
||||
// fetch was in flight — bail without invoking playTrack.
|
||||
if (isInOrbitSession()) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
if (newTracks.length === 0) {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
||||
import { useOrbitStore } from './orbitStore';
|
||||
|
||||
/** Minimal track info needed to surface the preview in the player bar UI. */
|
||||
export interface PreviewingTrack {
|
||||
@@ -75,6 +76,16 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
if (!auth.trackPreviewsEnabled) return;
|
||||
if (!auth.trackPreviewLocations[location]) return;
|
||||
|
||||
// Block preview during any Orbit session — the preview path runs
|
||||
// through the same Rust audio engine as the shared playback, and a
|
||||
// preview started by a guest would yank the host's track out from
|
||||
// under them. UI buttons are hidden via `[data-orbit-active]` CSS;
|
||||
// this guards keyboard shortcuts / programmatic callers.
|
||||
const orbit = useOrbitStore.getState();
|
||||
const inOrbit = (orbit.role === 'host' || orbit.role === 'guest')
|
||||
&& (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting');
|
||||
if (inOrbit) return;
|
||||
|
||||
const current = get().previewingId;
|
||||
if (current === song.id) {
|
||||
await get().stopPreview();
|
||||
|
||||
@@ -1907,6 +1907,14 @@
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Hide track preview controls inside an active Orbit session — preview
|
||||
playback shares the audio engine with the orbit-driven main player and
|
||||
the two would step on each other. The defensive guard in
|
||||
`previewStore.startPreview` catches keyboard / programmatic callers. */
|
||||
[data-orbit-active] .playlist-suggestion-preview-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-suggestions .track-row:hover .playlist-suggestion-preview-btn,
|
||||
.playlist-suggestion-preview-btn:hover,
|
||||
.playlist-suggestion-preview-btn.is-previewing {
|
||||
@@ -12579,12 +12587,18 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
/* Match the 26 px height of the other bar icon-buttons (.orbit-bar__settings)
|
||||
so the bar's vertical layout is stable whether or not catch-up is showing.
|
||||
The button used to be ~32 px tall, so it pushed the bar height up when it
|
||||
flickered in/out — visually annoying on a high-latency setup where the
|
||||
trigger fires intermittently. */
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
|
||||
Reference in New Issue
Block a user