mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
9b03949f340045a774d391560aa053b3c1e87e15
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b03949f34 |
feat(orbit): sync the host's track-transition settings to guests (#1158)
* feat(orbit): mirror host track-transition settings into the session Each client previously used its own local crossfade / gapless / AutoDJ settings, so guests blended tracks differently from the host and drifted out of sync — the exact drift the Catch-Up button exists to paper over. Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim, AutoDJ smooth-skip, gapless), additive on the wire so older sessions simply omit it. The host seeds its own prefs at start and refreshes them every tick, so a mid-session change propagates. Guests snapshot their own prefs on join, adopt the host's for the session (idempotent — no setState churn / audio-sync re-fire when unchanged), and restore their own on leave. Applying via authStore.setState reuses the existing authSyncListener path to the Rust engine, so no new IPC. Without the Hot cache a guest's AutoDJ blend degrades gracefully via the existing byte-preload fallback. Add a bridge module with unit tests for read/apply/save/restore. * feat(orbit): lock guest transition controls during a session Since a guest now mirrors the host's track-transition settings (re-applied every read tick), their own controls would visibly fight the sync. Disable them while a guest is in a live session and explain why. Settings -> Audio -> Track transitions greys out the mode picker, seconds slider and smooth-skip toggle with a "controlled by the Orbit host" note; the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same disabled state and tooltip. Host controls are untouched. New i18n key added across all locales. * docs(changelog): add Orbit transition sync (1158) |
||
|
|
a4fbd45d5d |
fix(orbit): host-side session guards — settings default, maxUsers, push serialisation (#1157)
* fix(orbit): unify session-settings default to the canonical preset
updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.
* fix(orbit): enforce maxUsers host-side in the participant rebuild
The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.
* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes
pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.
Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.
* docs(changelog): add Orbit host-side session guards (1157)
|
||
|
|
45b9229ceb |
refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3) cucadmuh's phase-3 steps: - Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[] fallback (until phase 4), F4 star/rating overrides merged on read. - QueueList rows source their track from the resolver (queue fallback); rows show title/artist/duration only, so no override merge there. - pendingStarSync star/rating success → invalidateQueueResolver so the cache reflects the synced value. - queueResolverBridge re-seeds on queueIndex change too — the prefetch window travels with the playing track. Additive: queue: Track[] stays canonical and behaviour is unchanged (rows resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks. * docs(changelog): queue panel reads through track cache (#860) * fix(queue): stop a render loop that froze the UI on long queues A long virtualized queue + a track change could lock the WebView for ~2 min: - useVirtualizer was handed a fresh `initialRect` object literal every render, so it kept re-initializing in a loop. Hoisted it to a stable module constant. - getCachedTrack did an LRU bump (Map delete+set) during render — a render-time side effect. Made it a pure read; recency is set at write time in cacheSet. * perf(mobile): virtualize the mobile player queue drawer The mobile now-playing queue drawer rendered the full queue with .map; a multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with @tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at O(visible rows), matching the desktop QueuePanel. Active track is centred on open via scrollToIndex. * perf(mini): virtualize the mini-player queue list The mini-player queue rendered the full MiniSyncPayload queue with .map. Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep data-mq-idx alongside the virtualizer's measureElement. * refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4) Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[]. Resolver cache → caller fallback (legacy queue[idx] during dual-write) → placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous read, no cache mutation (the freeze landmine), so it is safe in render. * refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4) Step 1b: dual-write the thin queueItems ref list at every queue write site (the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix, radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks queue: Track[] in memory, not only at persist time. Identity-preserving maps (star/rating overrides) keep the same refs and are intentionally left untouched. Resolves the restore double-role flagged for 1b: queueItemsIndex is now the restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems stays canonical -- rebuilt from the whole queue after a full hydrate instead of cleared. Normal mutations never set the sentinel, so it only fires on a fresh cold-start restore, not on later server switches. No behaviour change; queue: Track[] stays the source consumers read until phase 3. tsc + full vitest suite (1119 tests) green. * refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4) Step 2: the mobile now-playing queue drawer resolves each row's track from the resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop QueueList wired in the phase-3 commit. Subscribes to the resolver version so rows re-render as the cache fills. Structure (count, order, keys, the playTrack arg) still comes from queue: Track[] until it is dropped in the final step. The mobile drawer was the last queue display surface still reading track metadata straight off the fat queue. tsc + full vitest suite green. * refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4) Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[] (matching by trackId + the ref's queue-only flags) instead of Track[]. `bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new refs by id — purely structural (no resolver/override merge), so behaviour is byte-identical and playerStore.queue.test.ts stays unchanged green. The working ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the final step swaps that one line to state.queueItems once the fat queue is gone. enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks (seed-before-splice) so they resolve without a network round-trip after the fat queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems); dev-runtime only, silent in vitest and prod. tsc + full vitest suite (1119) green; contract test unchanged. * refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4) Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue mutations), and seed the resolver cache with the freshly fetched tracks so they resolve without a network round-trip after the fat queue is dropped. The radio top-up keeps its HISTORY_KEEP front-trim, now expressed on refs. The exhausted-queue refill paths hand their new queue to playTrack, which keeps its fat-queue handling until the final step (its no-arg case needs the resolver- derived queue that lands with the queue: Track[] removal). tsc + full vitest (1119) green; contract test unchanged. * refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4) Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[], killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display queue from the refs via resolveQueueTrack: resolver cache → the live queue by id (covers tracks the edit didn't remove) → placeholder. currentTrack stays a full track in the snapshot and is restored to the engine unchanged. The snapshot refs derive from queue: Track[] for now (so the undo/redo contract cases, which seed only `queue`, stay green); the final step swaps that to [...s.queueItems]. tsc + full vitest suite (1119) green. * perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4) Step 5: the mini bridge no longer serializes the full queue over IPC on every push — a 50k Artist-Radio queue would otherwise re-encode in full on every track advance. snapshot() sends a window of 100 tracks before/after the playing song; queueIndex is made slice-relative. The mini component stays unchanged (slice- relative); jump/reorder/remove control events are translated back to absolute queue indices via the window offset captured on the last push. tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly). * refactor(queue): make queueItems a required PlayerState field (thin-state phase 4) Foundation for the final consumer migration off queue: Track[]: queueItems has been written at every queue write site since phase 1b, so promoting it from optional to required is a no-op at runtime (tsc confirms zero new errors) and lets the upcoming reader migrations read state.queueItems without `?? []` noise. * refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4) First reader batch toward dropping queue: Track[]: the queue-length selectors (usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag) now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour- identical during dual-write (queueItems is in lockstep with queue). tsc + full vitest suite (1119) green. Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for now — it is called from many partially-mocked test stores, so it migrates with the final field removal where the seedQueue helper covers those tests. * refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4) The id/length reads (save to playlist, share link, create playlist, empty-queue guards, next-tracks divider) now read state.queueItems instead of the fat queue. Behaviour-identical during dual-write; queue: Track[] stays for the rendered QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green. * refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4) The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole canonical queue; full `Track`s resolve on demand via the resolver (index batch → getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At 50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track objects. - **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF). A `merge` migrates every historical blob shape → `queueItems` (existing `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops the obsolete `queue` key, so saved queues survive the upgrade. - **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole ref list into the cache on cold start (index → getSong, so an index-off queue still plays), clears the restore sentinel. - **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`. - **Mutations / actions / playback:** operate on refs; the playing track is `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation (next/previous/row-jump) keeps `queueItems` and only moves the index — no full resolve or queue rebuild per track change. - **Persist tests** cover the three old-blob migrations; `seedQueue` test helper replaces the `setState({ queue })` seeds. tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract; the gapless track change + cold-start restore + mini cap still want a live smoke before merge. * fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state) Rating/starring a queue song flashed the row's title to the "…" placeholder until the next track change. Root cause: on sync success pendingStarSync called invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[] gone there's no fat fallback, so the row resolved to a placeholder until the resolver bridge re-fetched the window. Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to update the cached entry in place (title kept, synced starred/userRating applied) instead of dropping it. No placeholder flash, no re-fetch. tsc + full vitest suite (1115) green. * fix(player): quota-safe persist so a full localStorage can't kill playback A very large queue (~50k refs) overflows the localStorage quota; the persist write then threw QuotaExceededError from inside set(), which aborted playTrack before audio_play — no audio output at all. Back the player persist with a quota-safe storage wrapper so a failed write degrades to a no-op instead of throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is left as a follow-up. * polish(player): throttle the quota-skip persist warning to once per key The quota-safe persist logs a skip on every failed write; on a huge queue that floods the dev console once per mutation. Warn once per key per quota-exceeded streak, re-armed when a write to that key next succeeds. * fix(queue): port new cover-pipeline readers to thin-state Main's cover pipeline (#870) reads s.queue.length and seeds the player store with queue: [track] in its tests. Under thin-state, queue: Track[] no longer exists — the canonical queue is queueItems: QueueItemRef[]. These four files were brought across in the merge but still spoke the old shape; this commit aligns them with the thin-state contract. - src/cover/usePlaybackCoverArt: queueLength = queueItems.length - src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs - src/api/coverCache.test: same - src/hooks/useNowPlayingPrewarm.test: same (two test cases) * fix(queue): canonicalize thin-state server identity for mixed-server queues `QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as the URL-derived index key on every writer path, matching the library index direction. Mixed-server queues with duplicate `trackId` across servers stay unambiguous because the resolver cache, persistence, and playback bindings all share one key shape. - new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer) - `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and `hydrateQueueFromIndex` emit canonical keys - `getCachedTrack` falls back to the canonical lookup so refs persisted in the legacy UUID shape still resolve through the migration window - persist `merge` rewrites `queueServerId` and every ref `serverId` on rehydrate, so the live store never holds mixed shapes - `removeServer` compares against the resolved id so a profile delete still clears the matching queue binding - the two `playbackServer.test.ts` asserts that hard-coded the UUID shape are updated to the canonical key (existing reader-tolerance is unchanged) * fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity When `applyQueueHistorySnapshot` has to prepend the still-playing track (the snapshot's queue does not contain it), the new ref must follow the snapshot's playback server, not the live `queueServerId`. A server switch racing the undo would otherwise stamp the prepended ref with the new server, mis-resolving the playing track on the very next render. - `QueueUndoSnapshot` now carries `queueServerId` (captured by `queueUndoSnapshotFromState`); older in-memory entries fall back through the snapshot's own refs and finally the live store value - the prepend in `applyQueueHistorySnapshot` plus the post-restore `seedQueueResolver` both source the server identity from this snapshot context, run through `canonicalQueueServerKey` so cache bucket and ref shape stay in lockstep * test(queue): regression cluster for mixed-server queues with duplicate trackId Covers the four invariants the thin-state review called out: - resolver correctness: same `trackId` on two servers maps to two distinct cache entries via canonical keys, and legacy UUID-shaped refs still read the same entries through the compat lookup path - restore/hydrate: persist `merge` forward-migrates UUID-form blobs in three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server `queueItems`) to canonical keys - undo snapshot application: prepended ref follows the snapshot's playback server even when the live queue has been rebound to a different one, with fallback to snapshot refs and live state for legacy entries - queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue` passes plain track ids and the playback server out of band, no per-ref `serverId` ever leaks into the request body Also asserts the write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`) emit canonical keys directly. * perf(queue-header): coalesce resolver burst updates and aggregate in one pass `QueueHeader` recomputed total and remaining queue durations on every resolver cache version bump via two separate full-queue reduces. A mass resolve burst (queue restore, prefetch window slide) bumps the version dozens of times in one frame, and very long queues turned that into visible main-thread stutter. - one pass: a single for-loop produces both total and future-tracks duration; a 50k-track queue costs one walk per recompute, not two - `useDeferredValue(version)` coalesces the burst into a single low-priority commit so the cache version is only sampled once per React frame instead of once per cache write * fix(queue): use stable artist seed for radio top-up The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and `getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next ref is still cold in the resolver cache, the placeholder track has empty artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently returning nothing and leaving the queue dry just before the radio rail would have refilled. - prefer the just-played `currentTrack` (always fully resolved in the player store) and the stored radio seed artist id - fall back to the next-track metadata only when those are missing - skip the top-up entirely when no stable seed is available, instead of emitting a non-deterministic empty request * docs(changelog): queue mixed-server routing and quota-safe persist (#872) |
||
|
|
946528350c |
refactor(orbit + shortcuts): unify host/guest heartbeat, document shortcut contract (Phase I) (#693)
* refactor(orbit): unify host/guest outbox heartbeat into a shared hook (Phase I) The outbox-heartbeat effect was duplicated near-verbatim in useOrbitHost and useOrbitGuest — same 10 s interval, same writeOrbitHeartbeat call, same cleanup; the only difference is whose name owns the outbox (OrbitState.host vs the active-server username). Extract it into useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, ownName). Host and guest each pass their own name source. The push/pull state-tick logic stays untouched — that asymmetry is the real host/guest difference, not duplication. Behaviour-preserving: the owner name is now a reactive hook arg instead of a getState() read inside the effect, so the heartbeat starts as soon as the name is available rather than waiting for an unrelated dep to change — a strict improvement, unreachable in practice since host name and username are fixed per session. * docs(shortcuts): document the shortcut-actions contract (Phase I) Add a contract reference block to the shortcutActions barrel — the three independent trigger surfaces (inApp / global / runInMiniWindow), the surface-independent cli + run fields, the dispatch entry points — and per-field doc comments on ShortcutActionMeta / ShortcutSlot / ActionContext / CliContext in shortcutTypes.ts. Pure documentation, no code change. |
||
|
|
7a7a9f5e6b |
refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio, cache, cover, share, server, playback, playlist, deviceSync, waveform, mix, format, export, changelog, ui, perf, componentHelpers). True singletons with no cluster stay at the utils/ root. Pure file-move: a path-aware codemod rewrote 539 relative-import specifiers across 275 files; no logic touched. The hot-path coverage gate list (.github/frontend-hot-path-files.txt) is updated to the new paths for the 11 gated utils files — a mechanical consequence of the move, not a CI change. tsc is green. |
||
|
|
006635de4b |
refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of `api/subsonic.ts`: - `subsonicLibrary.ts` — browse + random + per-song fetch (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`, `getRandomAlbums`, `getAlbumList`, `getRandomSongs`, `getRandomSongsFiltered`, `getSong`, `getAlbum`, `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus the private `albumIdsInActiveLibraryScope` cache). - `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`, `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`, `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and `similarSongsRequestCount` for the per-library scoping fallback. - `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers with the shared 7-min cache. Calls back into Library/Artists for the per-id fetch. 51 external call sites migrated to direct imports. No re-export shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts` keep their `RATING_CACHE_TTL` constant locally (same 7-min window). subsonic.ts: 1078 → 762 LOC (−316). |
||
|
|
9fac6eb490 |
refactor(player): E.42 — migrate playerStore re-exports to direct imports (#607)
Migrates ~74 call sites away from the playerStore re-export shims that were kept during M0–E.41 to avoid touching 30+ imports per PR. Now that the bigger refactor work is done, each helper goes back to its real home: - `initAudioListeners`, `installQueueUndoHotkey`, `flushPlayQueuePosition` → from their own store modules - `getPlaybackProgressSnapshot`, `subscribePlaybackProgress`, `PlaybackProgressSnapshot` → from `playbackProgress` - `resolveReplayGainDb`, `shuffleArray`, `songToTrack` → from `utils/*` - `_resetQueueUndoStacksForTest`, `consumePendingQueueListScrollTop`, `registerQueueListScrollTopReader` → from `queueUndo` - `PlayerState`, `Track` types → from `playerStoreTypes` Drops the corresponding 13 re-export stubs from `playerStore.ts` and the now-unused imports. Also drops dead section banners + per-wrapper comments above one-line action delegates. Trims one stale "(separate PR)" note in `transportLightActions.ts` since that follow-up landed in E.39. `playerStore.ts`: 180 → 112 LOC (−68). Down from Phase E's starting 3732 LOC. `bootstrap.test.ts` mock target updated from `../store/playerStore` to `../store/queueUndoHotkey` to keep the spy reachable after the import change. |
||
|
|
123fbcc802 |
fix(orbit): event-driven host push + guest seekbar lock (#537)
* fix(orbit): event-driven host push on play/pause flips Without this, the worst-case delay between "host hits pause" and "guest stops" is two full polling windows (host's 2.5 s push tick + guest's 2.5 s read tick, plus network) — long enough for the guest to noticeably run past the host. Subscribing to playerStore.isPlaying changes adds at most one extra remote write per flip; non-flip state ticks still ride the existing 2.5 s timer. The listener filters on isPlaying so the per-second currentTime ticks don't trigger spurious pushes. * fix(orbit): lock seekbar for guests — sync follows the host Guests could drag/click/wheel the seekbar, which would jump the local player and then snap back at the next host poll (2.5 s of inconsistent UX) — or push the guest into a diverged state where Catch Up was the only way back. The seekbar is host-controlled in Orbit; the guest input path now reflects that. - App.tsx exposes `data-orbit-role="host"|"guest"` on the root element alongside the existing `data-orbit-active` marker. - WaveformSeek's container gains a `.waveform-seek-container` class so CSS can target it. - Guest rule: `pointer-events: none` on children blocks click / drag / wheel / hover; the parent keeps `cursor: not-allowed` + reduced opacity so the disabled state is visually unambiguous. Hosts and non-orbit users see no change. * docs(changelog): credit PR #537 (orbit sync latency + guest seekbar) |
||
|
|
a702a5dd5b |
feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log Multiple users on Discord report Orbit guests stopping after the first song with no errors anywhere — Settings → Debug → Export Logs is too buried for non-technical reporters, and the relevant code branches have no logging at all (silent fail). This adds a one-click "Copy log" path right inside the Orbit session bar. The new Activity-icon button next to Help opens a popover with: - Live mini-display: role, host vs. guest track id + position, drift, age of the host's last state write — all updating once a second. - Scrolling event log textarea fed by an in-memory ring (200 events). - Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and drops them on the clipboard — paste straight into a Discord report. Instrumentation lands at the previously-silent decision points: - Guest pull tick: full snapshot of host vs. guest state on every read. - Each branch of the divergence detection in `useOrbitGuest.ts` logs which path it took and why (initial / track-change-followed / track-change-diverged / play-pause-flip), making the "stuck after first song" symptom diagnosable from the buffer alone. - Host pushes log track id, isPlaying, queue length, guest count. Events are also bridged to the existing `frontend_debug_log` Tauri command when Settings → Logging is on Debug, so power users still get the same data in `psysonic-logs-*.log` for offline triage. i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are native; ES / FR / NB / NL / RU / ZH are first-pass and may want a polish from native speakers later. * docs(changelog): add orbit diagnostics popover entry |
||
|
|
e4cc54a1b5 |
revert(orbit): drop maxPending cap feature, keep suggestion mute
The pending counter desynced from the actual approval list (state.queue holds approved items as history, so the count never decreased after a host approve). The host-pushed pendingApprovalCount workaround didn't hold up under live testing either, so we're rolling the whole cap feature back rather than ship something flaky. What's gone: - OrbitSettings.maxPending + state.pendingApprovalCount - cap branch in applyOutboxSnapshotsToState (now back to mute-only) - maxPending number input in settings popover - pending counter chip in OrbitQueueHead - 'cap-reached' branch in evaluateOrbitSuggestGate / OrbitSuggestGateReason - cap-related toasts in ContextMenu / useOrbitSongRowBehavior - cap-related i18n keys (suggestBlockedCap, settingMaxPending*, pendingCounter*) - cap CSS (.orbit-queue-head__pending, .orbit-settings-pop__number) What stays: per-guest suggestion mute (works correctly) and everything that fed into both features (OrbitState.suggestionBlocked, setOrbitSuggestionBlocked, evaluateOrbitSuggestGate, the participants popover Mic/MicOff toggle, the suggestBlockedMuted toast). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7c379c2111 |
fix(orbit): pending counter ignored merged/declined items
The "X / Y pending" counter in the queue head and the guest-side gate-check both used `state.queue.filter(non-host).length`, which is the *history* count — items the host has already approved or declined still sit in `state.queue` for attribution lookup, so the counter never decreased. Reported as "3 / 4 pending" with no actual rows in the approval list. The merged / declined sets only exist in the host's local store, so the guest can't filter them out itself. Solution: the host writes an authoritative `pendingApprovalCount` into the state blob each tick; guests (and the host's own UI) read it directly, with a fallback to the old over-counting behaviour for any older client that doesn't write the field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cfb7e7f6c1 |
feat(orbit): per-guest suggestion mute + global pending-cap setting
Two anti-spam knobs the host can dial during a live session: 1. Per-guest suggestion mute — Mic / MicOff toggle next to the kick/ban buttons in the participants popover. Symmetric (re-enable later). State lives in OrbitState.suggestionBlocked: string[]; the guest reads it and disables its own Suggest controls so the user sees a clear "muted" state instead of silent failures. Host-side sweep also drops their outbox entries as a safety net. 2. Max pending approvals cap — number input in the session-settings popover, default 0 (= unlimited so existing sessions are unaffected). When set, the host sweep stops folding new outbox entries into the approval list once the cap is reached. The OrbitQueueHead surfaces "X / Y pending" so guests can see when they're getting close. State changes are additive on the wire — both fields are optional, with parseOrbitState defaulting them, so older clients keep working. evaluateOrbitSuggestGate() centralises the guest-side allow/block check shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session items, plus suggestOrbitTrack as a defensive last line. i18n: en + de + fr + nl + zh + nb + ru + es. Also fixes an earlier dedupe-key collision: the (user, trackId) cache keys were missing their separator (NULL byte slipped in during the previous patch), so two tracks could share a key and one of them silently overwrite the other. Restored the space separator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
17bcac7155 |
chore(orbit): manual approval flow for guest suggestions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
089774dc3e |
chore(orbit): configurable shuffle interval
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f8bcd57ed4 |
chore(orbit): session-start polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11dddf6290 |
exp(orbit): iterative refinements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2b97a7b01e |
exp(orbit): host merge, guest queue view, settings popover, i18n
Guest suggestions now auto-merge into the host's play queue at random positions inside the upcoming range, so they actually surface alongside host-picked tracks instead of piling up at the end. Per-item dedupe via addedBy:addedAt:trackId keys survives reshuffles. Guests get a read-only mirror of the session in place of their queue panel — live-card for the host's current track, suggestion list with submitter attribution, and a footer reminding them the host owns playback. Track metadata is resolved lazily and cached locally. New host-only settings popover (gear in the bar) with auto-approve and auto-shuffle toggles plus a "Shuffle now" action. Settings live in the OrbitState blob and push immediately to Navidrome; missing fields on older blobs default to "both on". 15-min shuffle now touches the real playerStore queue via a new shuffleUpcomingQueue action — previous behaviour only reshuffled the guest-facing suggestion history. A manual shuffle is available from the settings popover so the interval can be verified without waiting. Start-modal reworked into a single step: share-link is visible and copy-able from the start, auto-copied on Start if the host hasn't already hit Copy. Link carries a live name-derived slug (parser strips it, SID is authoritative). LAN/remote-server hint above the facts. Random session-name suggester pulls from three pattern pools for ~10k unique combinations. All user-visible Orbit strings moved to a dedicated orbit i18n namespace (en + de); other locales fall back to en via i18next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cebf0e238d |
exp(orbit): periodic shuffle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
60e0bbfa2a |
exp(orbit): track pipeline and participants sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a45943d078 |
exp(orbit): host lifecycle and tick hook
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |