diff --git a/ORBIT.md b/ORBIT.md new file mode 100644 index 00000000..24b15dd2 --- /dev/null +++ b/ORBIT.md @@ -0,0 +1,344 @@ +# Psy Orbit + +A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue. + +No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it. + +--- + +## Table of contents + +- [For users](#for-users) + - [Starting a session (host)](#starting-a-session-host) + - [Joining (guest)](#joining-guest) + - [Suggesting tracks](#suggesting-tracks) + - [Approvals](#approvals) + - [Shared queue](#shared-queue) + - [Session settings](#session-settings) + - [Participants](#participants) + - [Ending the session](#ending-the-session) +- [Requirements & limits](#requirements--limits) +- [How it works (technical)](#how-it-works-technical) + - [Design goals](#design-goals) + - [Playlists as transport](#playlists-as-transport) + - [The host tick](#the-host-tick) + - [The guest tick](#the-guest-tick) + - [Data flow](#data-flow) + - [State shape](#state-shape) + - [Cleanup](#cleanup) + - [Security & privacy](#security--privacy) +- [Edge cases handled](#edge-cases-handled) +- [Code map](#code-map) + +--- + +## For users + +### Starting a session (host) + +Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with: + +- **Session name** — a random playful name is generated; edit it or reroll with the dice button. +- **Max guests** — cap on concurrent participants (1–32). You don't count. +- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name. +- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests. + +Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it. + +### Joining (guest) + +Two equivalent paths: + +1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join. +2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join. + +Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as. + +### Suggesting tracks + +Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search): + +- **Double-click** a row → adds just that track. +- **Right-click → "Add to Orbit session"** — same effect, via context menu. +- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone. + +Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it. + +### Approvals + +By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides. + +The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons: + +- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick. +- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip. + +Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed. + +### Shared queue + +Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that: + +- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range. +- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions. + +When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list. + +### Session settings + +Open via the gear icon in the session bar (host only): + +- **Auto-approve suggestions** — on/off. Default off. +- **Automatic reshuffle** — on/off. Periodically Fisher–Yates-shuffles the upcoming queue. +- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off. +- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer. + +### Participants + +Click the participant count in the session bar. Opens a popover: + +- Host row at the top with a crown icon. +- Each connected guest with a user icon, username, and join timestamp. +- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing. + +Guests see the same list but read-only — no action buttons. + +### Ending the session + +- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically. +- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else. +- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal. +- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat). + +### The help modal + +Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand). + +--- + +## Requirements & limits + +- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there. +- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username. +- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address. +- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible. +- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue. + +--- + +## How it works (technical) + +### Design goals + +1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own. +2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session. +3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch. +4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election. + +### Playlists as transport + +Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`): + +| Playlist | Who owns it | What's in it | +|---|---|---| +| `__psyorbit_` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. | +| `__psyorbit__from___` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. | + +All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active. + +### The host tick + +Fired every 2.5 s from `useOrbitHost`: + +1. **Sweep all outboxes.** List every `__psyorbit__from___` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment. +2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`. +3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's. +4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave. +5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, Fisher–Yates-reorder the upcoming play queue + rewrite `state.lastShuffle`. +6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob. +7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`. + +Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically. + +### The guest tick + +Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s: + +1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState. +2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout. +3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal. +4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically). +5. **Auto-sync to host.** Three cases: + - Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own). + - Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick. + - First tick after join → mirror unconditionally (initial sync). +6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment. + +### Data flow + +``` +Host (per tick) Navidrome Guest (per tick) +────────────────────────────────────────────────────────────────────────────────── + +player.currentTrack ++ position + │ + ▼ +snapshotPlayerPatch ──► writeOrbitState ─┐ + │ + ┌─session playlist─┐ + │ comment = JSON │ ◄─readOrbitState + └──────────────────┘ + │ + ▼ + parse OrbitState + │ + ▼ + syncToHost: + • getSong + • playTrack + • seek + • resume/pause + + +Guest suggests track Y + ┌────────────────────────────────────────────────────────────┤ + │ │ + ▼ │ + suggestOrbitTrack + ┌──guest outbox──┐ + │ track list = Y │ ◄─updatePlaylist + └────────────────┘ + │ + │ +Host: sweepGuestOutboxes ◄──────────┘ + │ + ▼ +applyOutboxSnapshotsToState +(queue += Y, participants refreshed) + │ + ▼ (if auto-approve) +mergeNewSuggestionsIntoQueue + │ + ▼ +player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles + pending list +``` + +### State shape + +All relevant types in `src/api/orbit.ts`: + +```ts +interface OrbitState { + v: 3; + sid: string; // 8 hex chars + host: string; // navidrome username + name: string; // human-readable session name + started: number; // ms since epoch + maxUsers: number; + currentTrack: OrbitQueueItem | null; + isPlaying: boolean; + positionMs: number; + positionAt: number; // wall-clock ms of the last snapshot + queue: OrbitQueueItem[]; // suggestion history + playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming + playQueueTotal: number; + participants: OrbitParticipant[]; + kicked: string[]; + removed: { user: string; at: number }[]; + lastShuffle: number; + settings: { + autoApprove: boolean; + autoShuffle: boolean; + shuffleIntervalMin: 1 | 5 | 10 | 15 | 30; + }; + ended: boolean; +} + +interface OrbitQueueItem { + trackId: string; + addedBy: string; // navidrome username + addedAt: number; +} +``` + +The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry. + +### Cleanup + +Three layers of defense against orphaned playlists: + +1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path. +2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states. +3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected. + +The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely. + +### Security & privacy + +- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer. +- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not. +- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server. +- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox. +- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs. + +--- + +## Edge cases handled + +- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock). +- **Host offline 15 s – 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally. +- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal. +- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync. +- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track". +- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op. +- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast. +- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable. +- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep. +- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror). +- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join. +- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected. +- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal. + +--- + +## Code map + +### State and types +- `src/api/orbit.ts` — `OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`. +- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`. + +### Lifecycle +- `src/utils/orbit.ts` — `startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`. +- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active. +- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch. + +### Hooks +- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat. +- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection. +- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists. + +### UI — session bar and popovers +- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons. +- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle). +- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button. +- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions). + +### UI — modals +- `src/components/OrbitStartModal.tsx` — session creation wizard. +- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join. +- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining. +- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice. +- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable). +- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help). + +### UI — queue views +- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge). +- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming). +- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline. + +### Supporting +- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate. +- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state. +- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch. diff --git a/package-lock.json b/package-lock.json index ad5eb055..937fd8b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.42.1", + "version": "1.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.42.1", + "version": "1.43.0", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", @@ -2671,9 +2671,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c7d7e38a..37c8adad 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4305,9 +4305,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6122200a..c2e43ff2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -557,6 +557,33 @@ async fn nd_delete_user( Ok(()) } +/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list. +/// Available to any authenticated user (no admin required). Returns raw JSON array. +#[tauri::command] +async fn nd_list_songs( + server_url: String, + token: String, + sort: String, + order: String, + start: u32, + end: u32, +) -> Result { + let url = format!( + "{}/api/song?_sort={}&_order={}&_start={}&_end={}", + server_url, sort, order, start, end + ); + let resp = nd_retry(|| { + nd_http_client() + .get(&url) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }).await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + /// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array. #[tauri::command] async fn nd_list_libraries( @@ -3803,6 +3830,14 @@ pub fn run() { } } + #[cfg(debug_assertions)] + { + use tauri::Manager; + if let Some(win) = app.get_webview_window("main") { + win.open_devtools(); + } + } + // ── System tray ─────────────────────────────────────────────── // Always build on startup when possible; the frontend calls toggle_tray_icon(false) // immediately after load if the user has disabled the tray icon. @@ -4048,6 +4083,7 @@ pub fn run() { nd_update_user, nd_delete_user, nd_list_libraries, + nd_list_songs, nd_set_user_libraries, nd_list_playlists, nd_create_playlist, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9aa7653f..94dd3472 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,8 +23,7 @@ "decorations": true, "transparent": false, "visible": true, - "dragDropEnabled": false, - "devtools": false + "dragDropEnabled": false } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 79e890fc..0e2a5000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,12 +46,16 @@ const LabelAlbums = lazy(() => import('./pages/LabelAlbums')); const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch')); const FolderBrowser = lazy(() => import('./pages/FolderBrowser')); const InternetRadio = lazy(() => import('./pages/InternetRadio')); +const Tracks = lazy(() => import('./pages/Tracks')); import MiniPlayer from './components/MiniPlayer'; import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; import DownloadFolderModal from './components/DownloadFolderModal'; +import GlobalConfirmModal from './components/GlobalConfirmModal'; +import OrbitAccountPicker from './components/OrbitAccountPicker'; +import OrbitHelpModal from './components/OrbitHelpModal'; import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; import OverlayScrollArea from './components/OverlayScrollArea'; @@ -64,6 +68,12 @@ 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 OrbitStartTrigger from './components/OrbitStartTrigger'; +import { useOrbitHost } from './hooks/useOrbitHost'; +import { useOrbitGuest } from './hooks/useOrbitGuest'; +import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit'; +import { useOrbitStore } from './store/orbitStore'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; @@ -131,6 +141,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(() => {}); @@ -236,6 +250,14 @@ function AppShell() { }; }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); + // Orbit orphan sweep — delete our own leftover session / outbox playlists + // from crashed or force-closed sessions so they don't clutter the ND + // playlist view. Runs once per login; safe and best-effort. + useEffect(() => { + if (!isLoggedIn || !activeServerId) return; + void cleanupOrphanedOrbitPlaylists(); + }, [isLoggedIn, activeServerId]); + // Reset scroll position on route change (main viewport is overlay scroll) useEffect(() => { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); @@ -421,6 +443,7 @@ function AppShell() { + + {connStatus === 'disconnected' && ( )} @@ -445,6 +469,7 @@ function AppShell() { } /> } /> + } /> } /> } /> } /> @@ -498,6 +523,9 @@ function AppShell() { + + + @@ -940,6 +968,19 @@ function TauriEventBridge() { await invoke('pause_rendering').catch(() => {}); await getCurrentWindow().hide(); } else { + // Clean up an active Orbit session before we go down — leaving + // the session playlists behind would litter the server. Capped at + // 1500 ms so a slow server can't keep the app hanging on quit; the + // next launch's orphan sweep is the safety net for anything that + // didn't make it out in time. + const role = useOrbitStore.getState().role; + if (role === 'host' || role === 'guest') { + const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession(); + await Promise.race([ + teardown.catch(() => {}), + new Promise(r => setTimeout(r, 1500)), + ]); + } await invoke('exit_app'); } }); @@ -1066,6 +1107,7 @@ export default function App() { return ( + ); diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts new file mode 100644 index 00000000..371a8e21 --- /dev/null +++ b/src/api/navidromeBrowse.ts @@ -0,0 +1,97 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { ndLogin } from './navidromeAdmin'; +import type { SubsonicSong } from './subsonic'; + +/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */ +let cachedToken: { serverUrl: string; token: string } | null = null; + +async function getToken(force = false): Promise { + const { getActiveServer, getBaseUrl } = useAuthStore.getState(); + const server = getActiveServer(); + const baseUrl = getBaseUrl(); + if (!server || !baseUrl) throw new Error('No active server configured'); + if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token; + const result = await ndLogin(baseUrl, server.username, server.password); + cachedToken = { serverUrl: baseUrl, token: result.token }; + return result.token; +} + +function asString(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback); +} + +function asNumber(v: unknown): number | undefined { + return typeof v === 'number' && isFinite(v) ? v : undefined; +} + +function mapNdSong(o: Record): SubsonicSong { + // Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract. + const id = asString(o.id ?? o.mediaFileId); + const albumId = asString(o.albumId); + return { + id, + title: asString(o.title), + artist: asString(o.artist), + album: asString(o.album), + albumId, + artistId: asString(o.artistId) || undefined, + duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0, + track: asNumber(o.trackNumber), + discNumber: asNumber(o.discNumber), + // Navidrome usually exposes coverArtId; many builds also accept the song id directly. + coverArt: asString(o.coverArtId) || albumId || id || undefined, + year: asNumber(o.year), + userRating: asNumber(o.rating), + starred: o.starred ? asString(o.starredAt) || 'true' : undefined, + genre: typeof o.genre === 'string' ? o.genre : undefined, + bitRate: asNumber(o.bitRate), + suffix: typeof o.suffix === 'string' ? o.suffix : undefined, + contentType: typeof o.contentType === 'string' ? o.contentType : undefined, + size: asNumber(o.size), + samplingRate: asNumber(o.sampleRate), + bitDepth: asNumber(o.bitDepth), + }; +} + +export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count'; + +/** + * Fetch a sorted, paginated slice of all songs via Navidrome's native REST API. + * Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome. + */ +export async function ndListSongs( + start: number, + end: number, + sort: NdSongSort = 'title', + order: 'ASC' | 'DESC' = 'ASC', +): Promise { + const baseUrl = useAuthStore.getState().getBaseUrl(); + if (!baseUrl) throw new Error('No server configured'); + + const callOnce = async (token: string): Promise => + invoke('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end }); + + let token = await getToken(); + let raw: unknown; + try { + raw = await callOnce(token); + } catch (err) { + const msg = String(err); + // Token rejected → re-auth once and retry + if (msg.includes('401') || msg.includes('403')) { + token = await getToken(true); + raw = await callOnce(token); + } else { + throw err; + } + } + + if (!Array.isArray(raw)) return []; + return raw.map(s => mapNdSong(s as Record)); +} + +/** Drop the cached token — call when the active server changes. */ +export function ndClearTokenCache(): void { + cachedToken = null; +} diff --git a/src/api/orbit.ts b/src/api/orbit.ts new file mode 100644 index 00000000..f98612ec --- /dev/null +++ b/src/api/orbit.ts @@ -0,0 +1,223 @@ +/** + * Orbit — shared-session state types. + * + * The canonical state blob lives in the comment field of a dedicated + * server-side playlist (`__psyorbit_[sid]__`). Host writes, guests read. + * Per-user "outbox" playlists (`__psyorbit_[sid]_from_[username]__`) + * carry suggestions + heartbeats the other way. + * + * This file is types + a few pure helpers only — no network, no store. + */ + +/** Bump whenever the on-wire schema changes incompatibly. */ +export const ORBIT_STATE_VERSION = 3 as const; + +/** Prefix for the canonical session playlist name. Append an 8-hex session id. */ +export const ORBIT_PLAYLIST_PREFIX = '__psyorbit_'; + +/** Full canonical session playlist name for a given session id. */ +export function orbitSessionPlaylistName(sessionId: string): string { + return `${ORBIT_PLAYLIST_PREFIX}${sessionId}__`; +} + +/** Full per-user outbox playlist name. Host reads these, guests own one. */ +export function orbitOutboxPlaylistName(sessionId: string, username: string): string { + return `${ORBIT_PLAYLIST_PREFIX}${sessionId}_from_${username}__`; +} + +/** One queued/current track + who added it, for attribution. */ +export interface OrbitQueueItem { + trackId: string; + /** Navidrome username of the participant who suggested this track. */ + addedBy: string; + /** Wall-clock ms when the host consumed this track from its originator's outbox. */ + addedAt: number; +} + +/** One participant's presence record. */ +export interface OrbitParticipant { + user: string; + /** Wall-clock ms when the host first registered this participant. */ + joinedAt: number; + /** Wall-clock ms of the participant's most recent outbox heartbeat. */ + lastHeartbeat: number; +} + +/** + * The canonical session state — exactly what's serialised into the + * session playlist's comment field. Keep lean; the comment has a ~4 KB + * self-imposed budget. + */ +export interface OrbitState { + v: typeof ORBIT_STATE_VERSION; + /** Session id (8 hex chars). */ + sid: string; + /** Navidrome username of the host. */ + host: string; + /** Human-readable session name set by the host at start. */ + name: string; + /** Epoch ms when the session was created. */ + started: number; + /** Host-configurable cap on concurrent participants. */ + maxUsers: number; + /** Currently-playing track (host's playback), or null when stopped. */ + currentTrack: OrbitQueueItem | null; + /** Host's live play/pause state. */ + isPlaying: boolean; + /** Host's last reported playback position in ms. */ + positionMs: number; + /** Wall-clock ms of the `positionMs` snapshot, for drift calculation. */ + positionAt: number; + /** Upcoming queue (not including `currentTrack`). */ + queue: OrbitQueueItem[]; + /** + * Snapshot of the host's actual upcoming play queue (everything after + * `queueIndex`), capped at `ORBIT_PLAY_QUEUE_LIMIT` to fit the state-blob + * byte budget. Used by the guest view so guests see what's next in the + * host's player rather than just the suggestions backlog. `addedBy` + * carries the original suggester when known, otherwise the host. + */ + playQueue?: { trackId: string; addedBy: string }[]; + /** + * Total length of the host's upcoming play queue, even when `playQueue` + * was truncated. Lets the guest UI render a "+ N more" hint. + */ + playQueueTotal?: number; + /** Epoch ms of the last queue shuffle. */ + lastShuffle: number; + /** Currently-present participants (excluding the host). */ + participants: OrbitParticipant[]; + /** Usernames blocked from re-joining this session. */ + kicked: string[]; + /** + * Soft-removed users — short-lived markers (TTL `ORBIT_REMOVED_TTL_MS`) + * so the affected guest's next poll surfaces a "you were removed" modal. + * Unlike `kicked`, the user is NOT blocked from re-joining via the + * invite link. Aged out by the host's sweep tick. + */ + removed?: { user: string; at: number }[]; + /** Set when the host has ended the session; guests should exit on next poll. */ + ended?: boolean; + /** Host-settable session rules; absent on older clients — treat missing as all-defaults. */ + settings?: OrbitSettings; + /** + * Usernames muted by the host: their outbox is still polled (so heartbeats + * keep them visible as participants) but new track suggestions are dropped + * before they reach the approval list. Symmetric — host can re-enable. + */ + suggestionBlocked?: string[]; +} + +/** + * Host-configurable rules. All default to `true`, i.e. the feature runs + * "all on" for new sessions. Toggled via the Orbit-bar settings popover. + */ +/** Minute presets offered to the host in the Orbit settings popover. */ +export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const; +export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number]; + +export interface OrbitSettings { + /** Guest suggestions go straight into the host's play queue. */ + autoApprove: boolean; + /** Whether the auto-shuffle cycle runs at all. */ + autoShuffle: boolean; + /** + * Minutes between each auto-shuffle cycle. Must be one of + * `ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN`. Older sessions that predate this + * field fall back to 15 via `effectiveShuffleIntervalMs`. + */ + shuffleIntervalMin?: OrbitShuffleIntervalMin; +} + +export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { + // Off by default — host decides per suggestion via the approval list. + autoApprove: false, + autoShuffle: true, + shuffleIntervalMin: 15, +}; + +/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */ +export interface OrbitOutboxMeta { + /** Wall-clock ms of this heartbeat. */ + ts: number; +} + +/** Our self-imposed limit on serialised `OrbitState`. Drop oldest non-essential fields if exceeded. */ +export const ORBIT_STATE_MAX_BYTES = 4096; + +/** Default value of `OrbitState.maxUsers` when the host hasn't picked one. */ +export const ORBIT_DEFAULT_MAX_USERS = 10; + +/** + * Hard cap on `playQueue` length. ~30 tracks × ~50 bytes each ≈ 1.5 KB, + * leaving room for the rest of the state blob under `ORBIT_STATE_MAX_BYTES`. + * Excess upcoming tracks are surfaced via the `playQueueTotal` count so the + * guest UI can show a "+ N more" hint instead of pretending there's nothing. + */ +export const ORBIT_PLAY_QUEUE_LIMIT = 30; + +/** + * Build a fresh state blob for a brand-new session. Used by the host on start. + */ +export function makeInitialOrbitState(args: { + sid: string; + host: string; + name: string; + maxUsers?: number; +}): OrbitState { + const now = Date.now(); + return { + v: ORBIT_STATE_VERSION, + sid: args.sid, + host: args.host, + name: args.name, + started: now, + maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS, + currentTrack: null, + isPlaying: false, + positionMs: 0, + positionAt: now, + queue: [], + lastShuffle: now, + participants: [], + kicked: [], + removed: [], + suggestionBlocked: [], + playQueue: [], + playQueueTotal: 0, + settings: { ...ORBIT_DEFAULT_SETTINGS }, + }; +} + +/** + * Validate + parse an incoming state blob (untrusted JSON from the playlist + * comment). Returns null on structural mismatch or schema-version drift. + */ +export function parseOrbitState(raw: unknown): OrbitState | null { + if (!raw || typeof raw !== 'object') return null; + const s = raw as Partial; + if (s.v !== ORBIT_STATE_VERSION) return null; + if (typeof s.sid !== 'string' || typeof s.host !== 'string') return null; + if (typeof s.name !== 'string' || typeof s.started !== 'number') return null; + if (typeof s.maxUsers !== 'number' || typeof s.isPlaying !== 'boolean') return null; + if (typeof s.positionMs !== 'number' || typeof s.positionAt !== 'number') return null; + if (!Array.isArray(s.queue) || !Array.isArray(s.participants) || !Array.isArray(s.kicked)) return null; + if (typeof s.lastShuffle !== 'number') return null; + // currentTrack can be null or an object — no deeper validation here; the + // producer is our own code and an item with missing fields would only hurt + // the attribution UI, not correctness. + // `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed. + if (!Array.isArray(s.removed)) s.removed = []; + // `suggestionBlocked` is optional too — older hosts predate the mute feature. + if (!Array.isArray(s.suggestionBlocked)) s.suggestionBlocked = []; + // `playQueue` / `playQueueTotal` are optional (older hosts won't write them). + if (!Array.isArray(s.playQueue)) s.playQueue = []; + if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0); + return s as OrbitState; +} + +/** Quickly derive the host's estimated live playback position on a guest. */ +export function estimateLivePosition(state: OrbitState, nowMs: number): number { + if (!state.isPlaying) return state.positionMs; + return state.positionMs + (nowMs - state.positionAt); +} diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 17456780..fef0f0f1 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -926,6 +926,23 @@ export async function search(query: string, options?: { albumCount?: number; art return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] }; } +/** + * Song-only paginated search3. Tolerates empty query — Navidrome returns all songs + * ordered by title in that case; strict Subsonic implementations may return nothing. + * Caller handles empty results gracefully (Tracks page falls back to its random pool). + */ +export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise { + const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', { + query, + artistCount: 0, + albumCount: 0, + songCount, + songOffset, + ...libraryFilterParams(), + }); + return data.searchResult3?.song ?? []; +} + export async function setRating(id: string, rating: number): Promise { await api('setRating.view', { id, rating }); } @@ -1018,9 +1035,14 @@ export function buildDownloadUrl(id: string): string { } // ─── Playlists ──────────────────────────────────────────────── -export async function getPlaylists(): Promise { +export async function getPlaylists(includeOrbit = false): Promise { const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() }); - return data.playlists?.playlist ?? []; + const all = data.playlists?.playlist ?? []; + // Orbit session + outbox playlists are technical internals. They're `public` + // so guests can reach them, which means they leak into every UI picker and + // even into the Navidrome web client. Filter them out of every UI call; + // orbit's own sweep passes `includeOrbit=true`. + return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_')); } export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 5fc4567f..c79900d2 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -57,6 +57,8 @@ interface AlbumTrackListProps { userRatingOverrides: Record; starredSongs: Set; onPlaySong: (song: SubsonicSong) => void; + /** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */ + onDoubleClickSong?: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void; @@ -80,6 +82,7 @@ interface TrackRowProps { inSelectMode: boolean; isContextMenuSong: boolean; onPlaySong: (song: SubsonicSong) => void; + onDoubleClickSong?: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void; onContextMenu: AlbumTrackListProps['onContextMenu']; @@ -100,6 +103,7 @@ const TrackRow = React.memo(function TrackRow({ inSelectMode, isContextMenuSong, onPlaySong, + onDoubleClickSong, onRate, onToggleSongStar, onContextMenu, @@ -224,6 +228,11 @@ const TrackRow = React.memo(function TrackRow({ onPlaySong(song); } }} + onDoubleClick={onDoubleClickSong ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || inSelectMode) return; + onDoubleClickSong(song); + } : undefined} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); @@ -266,6 +275,7 @@ export default function AlbumTrackList({ userRatingOverrides, starredSongs, onPlaySong, + onDoubleClickSong, onRate, onToggleSongStar, onContextMenu, @@ -644,6 +654,7 @@ export default function AlbumTrackList({ inSelectMode={inSelectMode} isContextMenuSong={contextMenuSongId === song.id} onPlaySong={onPlaySong} + onDoubleClickSong={onDoubleClickSong} onRate={onRate} onToggleSongStar={onToggleSongStar} onContextMenu={onContextMenu} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index a6fb2722..a47d1557 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -7,10 +7,15 @@ interface ConfirmModalProps { title: string; message: string; confirmLabel: string; - cancelLabel: string; + /** + * Cancel button label. Omit (together with `onCancel`) to render the + * modal as a single-button info dialog — Esc / outside-click / X then + * also resolve via `onConfirm`. + */ + cancelLabel?: string; danger?: boolean; onConfirm: () => void; - onCancel: () => void; + onCancel?: () => void; } export default function ConfirmModal({ @@ -23,15 +28,17 @@ export default function ConfirmModal({ onConfirm, onCancel, }: ConfirmModalProps) { + const dismiss = onCancel ?? onConfirm; + useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onCancel(); + if (e.key === 'Escape') dismiss(); else if (e.key === 'Enter') onConfirm(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [open, onCancel, onConfirm]); + }, [open, dismiss, onConfirm]); if (!open) return null; @@ -42,7 +49,7 @@ export default function ConfirmModal({ return createPortal(
e.stopPropagation()} style={{ maxWidth: '380px' }} > -

{title}

@@ -60,10 +67,12 @@ export default function ConfirmModal({ {message}

- - + )} +
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 4601872d..e8a4824d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2 } from 'lucide-react'; +import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; +import { useOrbitStore } from '../store/orbitStore'; +import { + suggestOrbitTrack, + hostEnqueueToOrbit, + evaluateOrbitSuggestGate, + OrbitSuggestBlockedError, +} from '../utils/orbit'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; @@ -962,6 +969,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play export default function ContextMenu() { const { t } = useTranslation(); + const orbitRole = useOrbitStore(s => s.role); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ contextMenu: s.contextMenu, @@ -1454,6 +1462,38 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (() => { + const muted = evaluateOrbitSuggestGate().reason === 'muted'; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (() => { + const muted = evaluateOrbitSuggestGate().reason === 'muted'; + return ( +
handleAction(() => { + if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } + suggestOrbitTrack(song.id) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + })} + > + {t('orbit.ctxAddToSession')} +
+ ); + })()} + {orbitRole === 'host' && ( +
handleAction(() => { + hostEnqueueToOrbit(song.id) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + })}> + {t('orbit.ctxAddToSessionHost')} +
+ )}
+ ); +} diff --git a/src/components/HostApprovalQueue.tsx b/src/components/HostApprovalQueue.tsx new file mode 100644 index 00000000..6f6779fe --- /dev/null +++ b/src/components/HostApprovalQueue.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Check, X, Inbox } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + approveOrbitSuggestion, + declineOrbitSuggestion, + suggestionKey, +} from '../utils/orbit'; +import { + getSong, + buildCoverArtUrl, + coverArtCacheKey, + type SubsonicSong, +} from '../api/subsonic'; +import CachedImage from './CachedImage'; +import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit'; + +/** + * Host-only approval strip. Renders directly below the OrbitQueueHead + * when `autoApprove === false` and at least one guest suggestion is + * waiting. Shows each pending track with Approve / Decline controls. + * + * Only rendered by the host-side render path (QueuePanel); guests never + * see this section — they watch their own pending list. + */ +export default function HostApprovalQueue() { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const state = useOrbitStore(s => s.state); + const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys); + const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys); + + const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS; + const autoApproveOff = settings.autoApprove === false; + + // Pending = everything in the session's suggestion history that isn't + // host-authored, isn't already merged, and hasn't been declined. + const pendingItems = useMemo(() => { + if (!state) return []; + const mergedSet = new Set(mergedKeys); + const declinedSet = new Set(declinedKeys); + return state.queue.filter(q => + q.addedBy !== state.host + && !mergedSet.has(suggestionKey(q)) + && !declinedSet.has(suggestionKey(q)) + ); + }, [state, mergedKeys, declinedKeys]); + + // Track-metadata cache (title/artist/cover) for the pending items. + const [songs, setSongs] = useState>({}); + const wantedKey = useMemo( + () => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'), + [pendingItems], + ); + useEffect(() => { + const wanted = wantedKey ? wantedKey.split('|') : []; + const missing = wanted.filter(id => id && !songs[id]); + if (missing.length === 0) return; + let cancelled = false; + void Promise.all(missing.map(id => getSong(id).catch(() => null))) + .then(results => { + if (cancelled) return; + setSongs(prev => { + const next = { ...prev }; + results.forEach((s, i) => { if (s) next[missing[i]] = s; }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) { + return null; + } + + return ( +
+
+ + {t('orbit.approvalTitle')} + {pendingItems.length} +
+
+ {pendingItems.map(q => { + const song = songs[q.trackId]; + const key = suggestionKey(q); + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
+ {t('orbit.approvalFrom', { user: q.addedBy })} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index c69e992b..b33b2604 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -157,9 +157,14 @@ export default function LiveSearch() {
{t('search.artists')}
{results.artists.map(a => { const i = idx++; + const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id; return ( - +

+ {t('orbit.accountPickerTitle')} +

+

+ {t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })} +

+
    + {accounts.map((a, i) => ( +
  • + +
  • + ))} +
+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx new file mode 100644 index 00000000..e9ee4dd8 --- /dev/null +++ b/src/components/OrbitExitModal.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { leaveOrbitSession } from '../utils/orbit'; + +/** + * Orbit — exit notification modal. + * + * Shown when: + * - `phase === 'ended'` (host closed the session; guest sees it) + * - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us) + * - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us; + * re-join via invite link still works) + * + * "OK" cleans up the guest-side outbox + resets the local store. + */ +export default function OrbitExitModal() { + const { t } = useTranslation(); + const phase = useOrbitStore(s => s.phase); + const errorMessage = useOrbitStore(s => s.errorMessage); + const role = useOrbitStore(s => s.role); + const sessionName = useOrbitStore(s => s.state?.name); + const hostName = useOrbitStore(s => s.state?.host); + + const isEnded = phase === 'ended'; + const isKicked = phase === 'error' && errorMessage === 'kicked'; + const isRemoved = phase === 'error' && errorMessage === 'removed'; + const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout'; + const isOpen = isEnded || isKicked || isRemoved || isHostTimeout; + + const onOk = async () => { + try { + if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + // Modal is informational with a single action — Enter / Escape both fire OK. + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + if (!isOpen) return null; + + const title = isKicked + ? t('orbit.exitKickedTitle') + : isRemoved + ? t('orbit.exitRemovedTitle') + : isHostTimeout + ? t('orbit.exitHostTimeoutTitle') + : t('orbit.exitEndedTitle'); + const body = isKicked + ? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' }) + : isRemoved + ? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' }) + : isHostTimeout + ? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' }) + : t('orbit.exitEndedBody', { name: sessionName ?? '' }); + + return createPortal( +
{ if (e.target === e.currentTarget) onOk(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-exit-title" + > +
+

{title}

+

{body}

+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx new file mode 100644 index 00000000..1a2e9056 --- /dev/null +++ b/src/components/OrbitGuestQueue.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Radio, Clock } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + getSong, + buildCoverArtUrl, + coverArtCacheKey, + type SubsonicSong, +} from '../api/subsonic'; +import CachedImage from './CachedImage'; +import OrbitQueueHead from './OrbitQueueHead'; + +/** + * Orbit — guest-side queue view. + * + * Rendered in place of the normal QueuePanel contents while `role === 'guest'`. + * Read-only: shows the host's current track + the host's actual upcoming + * play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No + * reorder / remove / save — those belong to the host. + * + * Track metadata is resolved lazily via `getSong` and cached locally so the + * list doesn't flicker while the 2.5 s state tick refreshes the snapshot. + */ +export default function OrbitGuestQueue() { + const { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const pending = useOrbitStore(s => s.pendingSuggestions); + const queueItems = state?.playQueue ?? []; + const totalUpcoming = state?.playQueueTotal ?? queueItems.length; + const truncatedBy = Math.max(0, totalUpcoming - queueItems.length); + const currentTrack = state?.currentTrack ?? null; + + // Local song cache — keyed by trackId. Survives parent re-renders triggered + // by the store tick so list rows don't remount and recomputed URLs don't + // kick off duplicate `getSong` calls. + const [songs, setSongs] = useState>({}); + + // Track IDs we need but don't yet have. String-joined so useEffect deps + // stay stable across identical queue snapshots (e.g. reshuffle). + const wantedKey = useMemo(() => { + const ids: string[] = []; + if (currentTrack) ids.push(currentTrack.trackId); + queueItems.forEach(q => ids.push(q.trackId)); + pending.forEach(id => ids.push(id)); + return Array.from(new Set(ids)).sort().join('|'); + }, [currentTrack, queueItems, pending]); + + useEffect(() => { + const wanted = wantedKey ? wantedKey.split('|') : []; + const missing = wanted.filter(id => id && !songs[id]); + if (missing.length === 0) return; + + let cancelled = false; + void Promise.all(missing.map(id => getSong(id).catch(() => null))) + .then(results => { + if (cancelled) return; + setSongs(prev => { + const next = { ...prev }; + results.forEach((s, i) => { if (s) next[missing[i]] = s; }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!state) return null; + + const currentSong = currentTrack ? songs[currentTrack.trackId] : null; + + return ( +
+ + + {currentTrack && ( +
+
+ {t('orbit.guestLive')} +
+
+ {currentSong?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {currentSong?.title ?? t('orbit.guestLoading')} +
+
+ {currentSong?.artist ?? ''} +
+
+ {state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')} +
+
+
+
+ )} + + {pending.length > 0 && ( +
+
+ + {t('orbit.guestPendingTitle')} + {pending.length} +
+ {pending.map(trackId => { + const song = songs[trackId]; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
{t('orbit.guestPendingHint')}
+
+
+ ); + })} +
+ )} + +
+ {t('orbit.guestUpNext')} {totalUpcoming} +
+ +
+ {queueItems.length === 0 && ( +
{t('orbit.guestUpNextEmpty')}
+ )} + + {queueItems.map((q, i) => { + const song = songs[q.trackId]; + const isHostPick = q.addedBy === state.host; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {song?.title ?? '…'} +
+
+ {song?.artist ?? ''} +
+
+ {isHostPick + ? t('orbit.queueAddedByHost') + : t('orbit.guestSubmitter', { user: q.addedBy })} +
+
+
+ ); + })} + + {truncatedBy > 0 && ( +
+ {t('orbit.guestUpNextMore', { count: truncatedBy })} +
+ )} +
+ +
{t('orbit.guestFooter')}
+
+ ); +} diff --git a/src/components/OrbitHelpModal.tsx b/src/components/OrbitHelpModal.tsx new file mode 100644 index 00000000..5a84f216 --- /dev/null +++ b/src/components/OrbitHelpModal.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, Sparkles, Users, Share2, LogIn, MousePointerClick, + ListMusic, Inbox, Sliders, LogOut, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useHelpModalStore } from '../store/helpModalStore'; +import SettingsSubSection from './SettingsSubSection'; + +/** + * Orbit help modal. Rendered once at the app root; triggered from the + * launch popover ("How does this work?") and the in-session bar's help + * button. 9 accordion sections built on SettingsSubSection; all default + * closed so the modal opens compact. Does not touch playback. + */ +export default function OrbitHelpModal() { + const { t } = useTranslation(); + const { isOpen, close } = useHelpModalStore(); + const bodyRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + // Focus the first accordion summary so arrow keys work immediately. + // Uses a short setTimeout because the browser re-focuses the clicked + // trigger button after the click handler returns — our focus call has + // to happen *after* that, otherwise the browser silently overrides it + // and the user only gets keyboard nav after pressing Tab first. + const id = window.setTimeout(() => { + const first = bodyRef.current?.querySelector('summary'); + first?.focus(); + }, 60); + return () => window.clearTimeout(id); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { close(); return; } + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + const summaries = Array.from( + bodyRef.current?.querySelectorAll('summary') ?? [], + ); + if (summaries.length === 0) return; + const current = document.activeElement as HTMLElement | null; + const idx = summaries.indexOf(current as HTMLElement); + e.preventDefault(); + const next = e.key === 'ArrowDown' + ? summaries[(idx + 1 + summaries.length) % summaries.length] + : summaries[(idx - 1 + summaries.length) % summaries.length]; + next?.focus(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, close]); + + if (!isOpen) return null; + + const hostOnlyLabel = t('orbit.helpHostOnly'); + + return createPortal( +
{ if (e.target === e.currentTarget) close(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-help-title" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
+ + +

+ {t('orbit.helpTitle')} +

+

{t('orbit.helpIntro')}

+ +
+ }> +

{t('orbit.helpSec1Body')}

+
+ + }> +

{t('orbit.helpSec2Body')}

+
+ {t('orbit.helpSec2WarnHead')} + {t('orbit.helpSec2WarnBody')} +
+
+ + }> +

{t('orbit.helpSec3Body')}

+
+ + }> +

{t('orbit.helpSec4Body')}

+
+ + }> +

{t('orbit.helpSec5Body')}

+
+ + }> +

{t('orbit.helpSec6Body')}

+
+ + } + > +

{t('orbit.helpSec7Body')}

+
+ + } + > +

{t('orbit.helpSec8Body')}

+
+ + }> +

{t('orbit.helpSec9Body')}

+
+
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitJoinModal.tsx b/src/components/OrbitJoinModal.tsx new file mode 100644 index 00000000..c20a777f --- /dev/null +++ b/src/components/OrbitJoinModal.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, LogIn, ClipboardPaste } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { + parseOrbitShareLink, + findSessionPlaylistId, + readOrbitState, + joinOrbitSession, +} from '../utils/orbit'; +import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; +import { showToast } from '../utils/toast'; + +interface Props { + onClose: () => void; +} + +/** + * Orbit — manual join modal. Alternative to the Ctrl+V paste shortcut for + * users who don't want to (or can't) paste the invite link into the app + * directly. Reuses the same parse + preflight pipeline the clipboard + * handler uses, so error surfaces stay consistent. + */ +export default function OrbitJoinModal({ onClose }: Props) { + const { t } = useTranslation(); + const [link, setLink] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const onPaste = async () => { + try { + const clip = await navigator.clipboard.readText(); + if (clip) setLink(clip); + } catch { /* silent — clipboard perms vary */ } + }; + + const onJoin = async () => { + setError(null); + const text = link.trim(); + if (!text) { setError(t('orbit.joinErrEmpty')); return; } + const parsed = parseOrbitShareLink(text); + if (!parsed) { setError(t('orbit.joinErrInvalid')); return; } + + const active = useAuthStore.getState().getActiveServer(); + const activeUrl = (active?.url ?? '').replace(/\/+$/, ''); + const wantUrl = parsed.serverBase.replace(/\/+$/, ''); + + setBusy(true); + try { + // Auto-switch to the link's server if the user has an account for it. + // Multiple candidates → picker modal. switch tears down any lingering + // orbit session. + if (activeUrl !== wantUrl) { + const candidates = useAuthStore.getState().servers + .filter(s => s.url.replace(/\/+$/, '') === wantUrl); + if (candidates.length === 0) { + setError(t('orbit.toastNoAccountForServer', { url: wantUrl })); + return; + } + const target = candidates.length === 1 + ? candidates[0] + : await useOrbitAccountPickerStore.getState().request(candidates); + if (!target) { setBusy(false); return; } + const switched = await switchActiveServer(target); + if (!switched) { + setError(t('orbit.toastSwitchFailed', { url: wantUrl })); + return; + } + } + + const playlistId = await findSessionPlaylistId(parsed.sid); + if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; } + const state = await readOrbitState(playlistId); + if (!state) { setError(t('orbit.joinErrNotFound')); return; } + if (state.ended) { setError(t('orbit.joinErrEnded')); return; } + await joinOrbitSession(parsed.sid); + showToast(t('orbit.toastJoined'), 2200, 'info'); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : t('orbit.toastJoinFail')); + } finally { + setBusy(false); + } + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-join-title" + > +
+ + +
+
+ +
+

+ {t('orbit.joinModalTitle')} +

+

{t('orbit.joinModalSub')}

+
+ +
+ +
+ { setLink(e.target.value); setError(null); }} + onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }} + placeholder={t('orbit.joinModalLinkPlaceholder')} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.joinModalLinkHelper')}
+
+ + {error &&
{error}
} + +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx new file mode 100644 index 00000000..db8404a3 --- /dev/null +++ b/src/components/OrbitParticipantsPopover.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit'; +import ConfirmModal from './ConfirmModal'; + +interface Props { + /** Anchor — we position the popover directly below its bottom-right. */ + anchorRef: React.RefObject; + onClose: () => void; +} + +function joinedFor(fromMs: number, nowMs: number): string { + const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000)); + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h${rm.toString().padStart(2, '0')}`; +} + +export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const role = useOrbitStore(s => s.role); + const popRef = useRef(null); + const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null); + const nowMs = Date.now(); + + // Close on outside click / Escape — unless a confirm dialog is open + // (otherwise outside-clicking the modal would dismiss the popover too, + // and re-opening would lose the in-flight confirm context). + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (confirm) return; + const t = e.target as Node | null; + if (popRef.current?.contains(t)) return; + if (anchorRef.current?.contains(t)) return; + onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (confirm) return; + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [anchorRef, onClose, confirm]); + + if (!state) return null; + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + left: Math.max(8, anchor.left - 100), + zIndex: 9999, + } + : { display: 'none' }; + + const onConfirm = async () => { + if (!confirm) return; + const { user, mode } = confirm; + setConfirm(null); + if (mode === 'remove') await removeOrbitParticipant(user); + else await kickOrbitParticipant(user); + }; + + return createPortal( + <> +
+
+ {t('orbit.participantsCountLabel', { count: state.participants.length + 1 })} +
+ +
+ + {state.host} + {t('orbit.participantsHost')} +
+ + {state.participants.length === 0 && ( +
{t('orbit.participantsEmpty')}
+ )} + + {state.participants.map(p => { + const isMuted = state.suggestionBlocked?.includes(p.user) ?? false; + return ( +
+ + {p.user} + {joinedFor(p.joinedAt, nowMs)} + {role === 'host' && ( +
+ + + +
+ )} +
+ ); + })} +
+ { void onConfirm(); }} + onCancel={() => setConfirm(null)} + /> + , + document.body, + ); +} diff --git a/src/components/OrbitQueueHead.tsx b/src/components/OrbitQueueHead.tsx new file mode 100644 index 00000000..da8f6a85 --- /dev/null +++ b/src/components/OrbitQueueHead.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { Users, Wifi, WifiOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import type { OrbitState } from '../api/orbit'; + +interface Props { + state: OrbitState; +} + +/** Host's state hasn't updated for this long → guest treats them as offline. */ +const HOST_AWAY_THRESHOLD_MS = 15_000; + +/** + * Shared Orbit head strip rendered at the top of the queue for both host + * and guest. Shows the session name and a comma-separated list of every + * participant (host first, then guests in join order). + * + * Guest view additionally surfaces host-presence: when the host's tick + * hasn't been seen for 15 s we render a subtle "host offline" badge so + * the guest knows the stalled playback isn't a local problem. + */ +export default function OrbitQueueHead({ state }: Props) { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Guest-only clock tick — React wouldn't re-render a stale state blob + // on its own, and the presence threshold is time-based. + useEffect(() => { + if (role !== 'guest') return; + const id = window.setInterval(() => setNowMs(Date.now()), 2000); + return () => window.clearInterval(id); + }, [role]); + + const names = [state.host, ...state.participants.map(p => p.user)]; + const showPresence = role === 'guest' && state.positionAt > 0; + const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS; + + return ( +
+
+

{state.name}

+ {showPresence && ( + + {hostAway ? : } + {t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')} + + )} +
+
+ + {names.join(', ')} +
+
+ ); +} diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx new file mode 100644 index 00000000..35dd8d24 --- /dev/null +++ b/src/components/OrbitSessionBar.tsx @@ -0,0 +1,269 @@ +import { useEffect, useRef, useState } from 'react'; +import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useHelpModalStore } from '../store/helpModalStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { getSong } from '../api/subsonic'; +import { + endOrbitSession, + leaveOrbitSession, + computeOrbitDriftMs, + effectiveShuffleIntervalMs, +} from '../utils/orbit'; +import { estimateLivePosition } from '../api/orbit'; +import OrbitParticipantsPopover from './OrbitParticipantsPopover'; +import OrbitExitModal from './OrbitExitModal'; +import OrbitSettingsPopover from './OrbitSettingsPopover'; +import OrbitSharePopover from './OrbitSharePopover'; +import ConfirmModal from './ConfirmModal'; + +/** + * Orbit — top-strip session indicator. + * + * Visible whenever the local store reports an active (or just-ended) + * session. Shows session name, host, participant count, shuffle countdown, + * and role-appropriate action buttons (catch-up for guests, exit for + * everyone). + * + * Deliberately low-chrome: sits above the rest of the app without + * reshaping the layout. + */ + +const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000; + +function formatCountdown(ms: number): string { + const clamped = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(clamped / 60); + const s = clamped % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export default function OrbitSessionBar() { + const { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + const errorMessage = useOrbitStore(s => s.errorMessage); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [peopleOpen, setPeopleOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [confirmLeave, setConfirmLeave] = useState(false); + const peopleBtnRef = useRef(null); + const settingsBtnRef = useRef(null); + const shareBtnRef = useRef(null); + + // 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]); + + // Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed. + const shouldShowBar = !!state && ( + phase === 'active' + || phase === 'ended' + || (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed')) + ); + if (!shouldShowBar || !state) return ( + + ); + + 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(); + else if (role === 'guest') await leaveOrbitSession(); + else useOrbitStore.getState().reset(); + } catch { + useOrbitStore.getState().reset(); + } + }; + + const onExit = () => { + // Active-session exits get a confirm — guests don't want to drop out on + // a fat-finger, and the host's X ends the session for everyone, so + // accidentally clicking it is even worse. Post-end/kicked dismissals + // skip the confirm (the session is already over there). + if (phase === 'active' && (role === 'guest' || role === 'host')) { + setConfirmLeave(true); + return; + } + void performExit(); + }; + + 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); + 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) { + 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]); + window.setTimeout(() => { + const p = usePlayerStore.getState(); + if (p.currentTrack?.id !== trackId) return; + p.seek(fraction); + if (!hostPlaying && p.isPlaying) p.pause(); + }, 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 ( +
+
+
+ +
+ + + {t('orbit.shuffleLabel')} + {formatCountdown(untilShuffle)} + +
+ +
+ {role === 'host' && ( + + )} + {role === 'host' && ( + + )} + {showCatchUp && ( + + )} + + +
+ + {peopleOpen && ( + setPeopleOpen(false)} + /> + )} + {settingsOpen && ( + setSettingsOpen(false)} + /> + )} + {shareOpen && ( + setShareOpen(false)} + /> + )} + + { setConfirmLeave(false); void performExit(); }} + onCancel={() => setConfirmLeave(false)} + /> +
+ ); +} diff --git a/src/components/OrbitSettingsPopover.tsx b/src/components/OrbitSettingsPopover.tsx new file mode 100644 index 00000000..2070052f --- /dev/null +++ b/src/components/OrbitSettingsPopover.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Shuffle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit'; +import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '../api/orbit'; +import { showToast } from '../utils/toast'; + +interface Props { + anchorRef: React.RefObject; + onClose: () => void; +} + +/** + * Host-only popover anchored below the settings button in the Orbit bar. + * Two toggles; writes are pushed immediately to Navidrome via + * `updateOrbitSettings`. + */ +export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); + const settings = useOrbitStore(s => s.state?.settings) ?? ORBIT_DEFAULT_SETTINGS; + const popRef = useRef(null); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (anchorRef.current?.contains(target)) return; + onClose(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [anchorRef, onClose]); + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + right: Math.max(8, window.innerWidth - anchor.right), + zIndex: 9999, + } + : { display: 'none' }; + + return createPortal( +
+
{t('orbit.settingsTitle')}
+ + + + + +
+
+
{t('orbit.settingShuffleInterval')}
+
{t('orbit.settingShuffleIntervalHint')}
+
+
+ {ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => { + const active = (settings.shuffleIntervalMin ?? 15) === min; + return ( + + ); + })} +
+
+ + +
, + document.body, + ); +} diff --git a/src/components/OrbitSharePopover.tsx b/src/components/OrbitSharePopover.tsx new file mode 100644 index 00000000..e0b88c9f --- /dev/null +++ b/src/components/OrbitSharePopover.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Copy, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useAuthStore } from '../store/authStore'; +import { buildOrbitShareLink } from '../utils/orbit'; + +interface Props { + anchorRef: React.RefObject; + onClose: () => void; +} + +/** + * Host-only popover anchored below the share button in the Orbit bar. + * Surfaces the session invite link with a copy affordance. Lives on its + * own so the participants popover can stay focused on participants. + */ +export default function OrbitSharePopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); + const sessionId = useOrbitStore(s => s.sessionId); + const popRef = useRef(null); + const [copied, setCopied] = useState(false); + + const shareLink = sessionId + ? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId) + : null; + + useEffect(() => { + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (anchorRef.current?.contains(target)) return; + onClose(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [anchorRef, onClose]); + + const onCopy = async () => { + if (!shareLink) return; + try { + await navigator.clipboard.writeText(shareLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { /* silent */ } + }; + + if (!shareLink) return null; + + const anchor = anchorRef.current?.getBoundingClientRect(); + const style: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 12, + right: Math.max(8, window.innerWidth - anchor.right), + zIndex: 9999, + } + : { display: 'none' }; + + return createPortal( +
+
{t('orbit.participantsInviteLabel')}
+
+ {shareLink} + +
+
, + document.body, + ); +} diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx new file mode 100644 index 00000000..95c13b9a --- /dev/null +++ b/src/components/OrbitStartModal.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { + X, Check, Copy, Orbit as OrbitIcon, + Dices, AlertTriangle, Globe2, +} from 'lucide-react'; +import { + startOrbitSession, + buildOrbitShareLink, + generateSessionId, +} from '../utils/orbit'; +import { randomOrbitSessionName } from '../utils/orbitNames'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { isLanUrl } from '../hooks/useConnectionStatus'; +import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; + +interface Props { onClose: () => void; } + +/** + * Orbit — start-session modal. + * + * One-screen flow: a share-link is shown immediately (built from a + * pre-generated session id + a slug derived from the live name). The host + * can copy it any time; pressing "Start" creates the session under that + * same id and auto-copies the link if it hasn't been copied yet. + */ +export default function OrbitStartModal({ onClose }: Props) { + const { t } = useTranslation(); + const [sid] = useState(() => generateSessionId()); + const [name, setName] = useState(() => randomOrbitSessionName()); + const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [hasCopied, setHasCopied] = useState(false); + const [clearQueue, setClearQueue] = useState(false); + + const server = useAuthStore.getState().getActiveServer(); + const serverBase = server?.url ?? ''; + const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer'); + const onLan = isLanUrl(serverBase); + + const shareLink = useMemo( + () => buildOrbitShareLink(serverBase, sid), + [serverBase, sid], + ); + + const writeLinkToClipboard = async (): Promise => { + try { + await navigator.clipboard.writeText(shareLink); + return true; + } catch { + return false; + } + }; + + const onCopy = async () => { + const ok = await writeLinkToClipboard(); + if (ok) { + setCopied(true); + setHasCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } + }; + + const onStart = async () => { + setError(null); + const trimmed = name.trim(); + if (!trimmed) { setError(t('orbit.errNameRequired')); return; } + + if (!hasCopied) { + const ok = await writeLinkToClipboard(); + if (ok) setHasCopied(true); + } + + setBusy(true); + try { + if (clearQueue) usePlayerStore.getState().clearQueue(); + await startOrbitSession({ name: trimmed, maxUsers, sid }); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : t('orbit.errStartFailed')); + } finally { + setBusy(false); + } + }; + + const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName)); + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-start-title" + > +
+ + +
+
+ +
+

+ {t('orbit.heroTitlePrefix')}{' '} + {t('orbit.heroTitleBrand')} +

+

+ {heroSubParts[0]} + {serverName} + {heroSubParts[1] ?? ''} +

+
+ +
+ {onLan ? : } + {onLan ? t('orbit.tipLan') : t('orbit.tipRemote')} +
+ +
+ +
+ { setName(e.target.value); setHasCopied(false); }} + onKeyDown={e => { + if (e.key !== 'Enter') return; + if (busy || !name.trim()) return; + e.preventDefault(); + void onStart(); + }} + placeholder={t('orbit.namePlaceholder')} + maxLength={40} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.helperName')}
+
+ +
+ + setMaxUsers(Number(e.target.value))} + className="orbit-start-modal__range" + /> +
{t('orbit.helperMax')}
+
+ +
+ +
+ +
+ +
+ {shareLink} + +
+
{t('orbit.helperLink')}
+
+ + {error &&
{error}
} + +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitStartTrigger.tsx b/src/components/OrbitStartTrigger.tsx new file mode 100644 index 00000000..061156e9 --- /dev/null +++ b/src/components/OrbitStartTrigger.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { useAuthStore } from '../store/authStore'; +import { useHelpModalStore } from '../store/helpModalStore'; +import OrbitStartModal from './OrbitStartModal'; +import OrbitJoinModal from './OrbitJoinModal'; +import OrbitWordmark from './OrbitWordmark'; + +/** + * Topbar trigger — opens a small launch popover offering three choices: + * create a new session, join an existing one via invite link, or open the + * Orbit help section. Hidden while a session is already active so we + * don't offer entry points while the user's session bar is already live. + */ +export default function OrbitStartTrigger() { + const { t } = useTranslation(); + const role = useOrbitStore(s => s.role); + const visible = useAuthStore(s => s.showOrbitTrigger); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [startOpen, setStartOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + + // Close popover on outside click / Escape. + useEffect(() => { + if (!popoverOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node | null; + if (popRef.current?.contains(target)) return; + if (btnRef.current?.contains(target)) return; + setPopoverOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [popoverOpen]); + + if (role !== null) return null; + if (!visible) return null; + + const anchor = btnRef.current?.getBoundingClientRect(); + const popoverStyle: React.CSSProperties = anchor + ? { + position: 'fixed', + top: anchor.bottom + 8, + left: anchor.left, + zIndex: 9999, + } + : { display: 'none' }; + + const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); }; + const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); }; + const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); }; + + return ( + <> + + + {popoverOpen && createPortal( +
+ + + +
, + document.body, + )} + + {startOpen && setStartOpen(false)} />} + {joinOpen && setJoinOpen(false)} />} + + ); +} diff --git a/src/components/OrbitWordmark.tsx b/src/components/OrbitWordmark.tsx new file mode 100644 index 00000000..07d9d40f --- /dev/null +++ b/src/components/OrbitWordmark.tsx @@ -0,0 +1,27 @@ +interface Props { + height?: number; + className?: string; +} + +export default function OrbitWordmark({ height = 14, className }: Props) { + const aspect = 196.08815 / 57.845078; + return ( + + ); +} diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx index 389866cd..7bd4e7c3 100644 --- a/src/components/PasteClipboardHandler.tsx +++ b/src/components/PasteClipboardHandler.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; @@ -6,6 +6,25 @@ import { decodeSharePayloadFromText } from '../utils/shareLink'; import { decodeServerMagicStringFromText } from '../utils/serverMagicString'; import { applySharePastePayload } from '../utils/applySharePaste'; import { showToast } from '../utils/toast'; +import { + parseOrbitShareLink, + joinOrbitSession, + findSessionPlaylistId, + readOrbitState, + OrbitJoinError, +} from '../utils/orbit'; +import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; +import ConfirmModal from './ConfirmModal'; + +const ORBIT_JOIN_ERROR_KEYS: Record = { + 'not-found': 'orbit.joinErrNotFound', + 'ended': 'orbit.joinErrEnded', + 'full': 'orbit.joinErrFull', + 'kicked': 'orbit.joinErrKicked', + 'no-user': 'orbit.joinErrNoUser', + 'server-error': 'orbit.joinErrServerError', +}; /** * Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`) @@ -16,6 +35,33 @@ export default function PasteClipboardHandler() { const { t } = useTranslation(); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const busy = useRef(false); + const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null); + const [orbitInvalid, setOrbitInvalid] = useState(false); + + // `not-found` and `ended` collapse into a single "link no longer valid" + // dialog — from the guest's POV both mean the same thing: the invite + // doesn't lead anywhere any more. Other reasons stay as toasts because + // they're actionable (full → wait, kicked → talk to host, etc.). + const handleJoinError = (reason: string | null, fallback?: string) => { + if (reason === 'not-found' || reason === 'ended') { + setOrbitInvalid(true); + return; + } + const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null; + showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error'); + }; + + const runOrbitJoin = (sid: string) => { + if (busy.current) return; + busy.current = true; + joinOrbitSession(sid) + .then(() => showToast(t('orbit.toastJoined'), 2500, 'info')) + .catch(err => { + if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message); + else handleJoinError(null); + }) + .finally(() => { busy.current = false; }); + }; useEffect(() => { const onPaste = (e: ClipboardEvent) => { @@ -30,6 +76,59 @@ export default function PasteClipboardHandler() { return; } const text = e.clipboardData?.getData('text/plain') ?? ''; + + // Orbit share link — handled before library shares. + const orbit = parseOrbitShareLink(text.trim()); + if (orbit) { + e.preventDefault(); + e.stopPropagation(); + if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; } + if (busy.current) return; + busy.current = true; + + (async () => { + const active = useAuthStore.getState().getActiveServer(); + const activeUrl = (active?.url ?? '').replace(/\/+$/, ''); + const wantUrl = orbit.serverBase.replace(/\/+$/, ''); + + // Auto-switch to the link's target server if the user has an + // account registered for it. No account → clear error. Multiple + // accounts for the same URL → picker lets the user choose. The + // switch itself tears down any lingering orbit session (see + // switchActiveServer) so the join below starts clean. + if (activeUrl !== wantUrl) { + const candidates = useAuthStore.getState().servers + .filter(s => s.url.replace(/\/+$/, '') === wantUrl); + if (candidates.length === 0) { + showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning'); + return; + } + const target = candidates.length === 1 + ? candidates[0] + : await useOrbitAccountPickerStore.getState().request(candidates); + if (!target) return; // cancelled + const switched = await switchActiveServer(target); + if (!switched) { + showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error'); + return; + } + } + + // Preview the session state so the confirm dialog can show the + // host and session name. Failures surface the same error toasts + // the join would, without ever showing the confirm. + const playlistId = await findSessionPlaylistId(orbit.sid); + if (!playlistId) { handleJoinError('not-found'); return; } + const state = await readOrbitState(playlistId); + if (!state) { handleJoinError('not-found'); return; } + if (state.ended) { handleJoinError('ended'); return; } + setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name }); + })() + .catch(() => handleJoinError(null)) + .finally(() => { busy.current = false; }); + return; + } + const share = decodeSharePayloadFromText(text); if (share) { if (!isLoggedIn) { @@ -74,5 +173,31 @@ export default function PasteClipboardHandler() { return () => document.removeEventListener('paste', onPaste, true); }, [navigate, t, isLoggedIn]); - return null; + return ( + <> + { + const sid = orbitConfirm?.sid; + setOrbitConfirm(null); + if (sid) runOrbitJoin(sid); + }} + onCancel={() => setOrbitConfirm(null)} + /> + setOrbitInvalid(false)} + /> + + ); } diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index bb1edfaf..1e4dfa68 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,5 +1,9 @@ import React, { useState, useRef, useMemo, useEffect } from 'react'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; +import { useOrbitStore } from '../store/orbitStore'; +import OrbitGuestQueue from './OrbitGuestQueue'; +import OrbitQueueHead from './OrbitQueueHead'; +import HostApprovalQueue from './HostApprovalQueue'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { usePlaylistStore } from '../store/playlistStore'; @@ -231,8 +235,44 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim } export default function QueuePanel() { + const orbitRole = useOrbitStore(s => s.role); + if (orbitRole === 'guest') { + return ( + + ); + } + return ; +} + +function QueuePanelHostOrSolo() { const { t } = useTranslation(); const navigate = useNavigate(); + const orbitRole = useOrbitStore(s => s.role); + const orbitState = useOrbitStore(s => s.state); + /** trackId → addedBy (host username or guest username) — only populated while + * hosting an Orbit session, so the queue rows can surface attribution. */ + const orbitAddedByByTrack = useMemo(() => { + const map = new Map(); + if (orbitRole !== 'host' || !orbitState) return map; + if (orbitState.currentTrack) { + map.set(orbitState.currentTrack.trackId, orbitState.currentTrack.addedBy); + } + for (const q of orbitState.queue) map.set(q.trackId, q.addedBy); + return map; + }, [orbitRole, orbitState]); + const orbitHostUsername = orbitState?.host ?? ''; + /** Attribution label for a queue row / current track while hosting. Null when + * not in a hosted session. Bulk-adds (album / playlist enqueue) bypass + * `hostEnqueueToOrbit` and therefore never land in `state.queue`, so we + * default those to "Added by you" rather than showing nothing. */ + const orbitAttributionLabel = (trackId: string): string | null => { + if (orbitRole !== 'host' || !orbitState) return null; + const addedBy = orbitAddedByByTrack.get(trackId); + if (!addedBy || addedBy === orbitHostUsername) return t('orbit.queueAddedByYou'); + return t('orbit.queueAddedByUser', { user: addedBy }); + }; const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -456,6 +496,12 @@ export default function QueuePanel() { borderLeftWidth: isQueueVisible ? 1 : 0, }} > + {orbitRole === 'host' && orbitState && ( + <> + + + + )} {currentTrack.year}
)} + {(() => { + const label = orbitAttributionLabel(currentTrack.id); + return label ?
{label}
: null; + })()} {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
@@ -759,6 +809,10 @@ export default function QueuePanel() { {track.title}
{track.artist}
+ {(() => { + const label = orbitAttributionLabel(track.id); + return label ?
{label}
: null; + })()}
{formatTime(track.duration)} diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx new file mode 100644 index 00000000..bfce2e4b --- /dev/null +++ b/src/components/SongCard.tsx @@ -0,0 +1,126 @@ +import React, { memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Play, ListPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import CachedImage from './CachedImage'; +import { enqueueAndPlay } from '../utils/playSong'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; + +interface SongCardProps { + song: SubsonicSong; +} + +function SongCard({ song }: SongCardProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const enqueue = usePlayerStore(s => s.enqueue); + const coverUrl = song.coverArt ? buildCoverArtUrl(song.coverArt, 200) : ''; + const psyDrag = useDragDrop(); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + + const handlePlay = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueueAndPlay(song); + }; + + const handleEnqueue = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueue([songToTrack(song)]); + }; + + const handleClick = handlePlay; + + const handleArtistClick = (e: React.MouseEvent) => { + if (!song.artistId) return; + e.stopPropagation(); + navigate(`/artist/${song.artistId}`); + }; + + return ( +
e.key === 'Enter' && handleClick()} + onContextMenu={(e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, song, 'song'); + }} + onMouseDown={e => { + if (e.button !== 0) return; + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + psyDrag.startDrag( + { data: JSON.stringify({ type: 'song', id: song.id, name: song.title }), label: song.title, coverUrl: coverUrl || undefined }, + me.clientX, me.clientY, + ); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > +
+ {coverUrl ? ( + + ) : ( +
+ + + + +
+ )} +
+ + +
+
+
+

{song.title}

+

{song.artist}

+
+
+ ); +} + +export default memo(SongCard); diff --git a/src/components/SongRail.tsx b/src/components/SongRail.tsx new file mode 100644 index 00000000..9dd25fbe --- /dev/null +++ b/src/components/SongRail.tsx @@ -0,0 +1,91 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; +import { SubsonicSong } from '../api/subsonic'; +import SongCard from './SongCard'; + +interface Props { + title: string; + songs: SubsonicSong[]; + /** Called when user clicks the reroll button (visible only if provided). */ + onReroll?: () => void | Promise; + /** Loading state — disables reroll, optional shimmer */ + loading?: boolean; + /** Empty-state copy when songs is empty AND not loading. */ + emptyText?: string; +} + +export default function SongRail({ title, songs, onReroll, loading, emptyText }: Props) { + const scrollRef = useRef(null); + const [showLeft, setShowLeft] = useState(false); + const [showRight, setShowRight] = useState(true); + + const handleScroll = () => { + if (!scrollRef.current) return; + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; + setShowLeft(scrollLeft > 0); + setShowRight(scrollLeft < scrollWidth - clientWidth - 5); + }; + + useEffect(() => { + handleScroll(); + window.addEventListener('resize', handleScroll); + return () => window.removeEventListener('resize', handleScroll); + }, [songs]); + + const scroll = (dir: 'left' | 'right') => { + if (!scrollRef.current) return; + const amount = scrollRef.current.clientWidth * 0.75; + scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' }); + }; + + // Hide rail entirely if empty and no empty-state copy + if (songs.length === 0 && !loading && !emptyText) return null; + + return ( +
+
+

{title}

+
+ {onReroll && ( + + )} + + +
+
+ +
+ {songs.length === 0 && emptyText ? ( +

{emptyText}

+ ) : ( +
+ {songs.map(s => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/SongRow.tsx b/src/components/SongRow.tsx new file mode 100644 index 00000000..36873eae --- /dev/null +++ b/src/components/SongRow.tsx @@ -0,0 +1,130 @@ +import React, { memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Play, ListPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { SubsonicSong } from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { enqueueAndPlay } from '../utils/playSong'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; + +function fmtDuration(s: number): string { + if (!s || !isFinite(s)) return '–'; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; +} + +interface Props { + song: SubsonicSong; +} + +function SongRow({ song }: Props) { + const navigate = useNavigate(); + const enqueue = usePlayerStore(s => s.enqueue); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id); + const psyDrag = useDragDrop(); + const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + + // In an orbit session both buttons collapse into the orbit-suggest / host-enqueue + // path so we don't ship a queue replacement to every guest. + const handlePlay = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueueAndPlay(song); + }; + + const handleEnqueue = () => { + if (orbitActive) { addTrackToOrbit(song.id); return; } + enqueue([songToTrack(song)]); + }; + + return ( +
{ + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, song, 'song'); + }} + onMouseDown={(e) => { + if (e.button !== 0) return; + const sx = e.clientX, sy = e.clientY; + const track = songToTrack(song); + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + psyDrag.startDrag( + { data: JSON.stringify({ type: 'song', track }), label: song.title }, + me.clientX, me.clientY, + ); + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + > +
+ + +
+
{song.title}
+
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }} + title={song.artist} + >{song.artist} +
+
+ {song.albumId ? ( + { e.stopPropagation(); navigate(`/album/${song.albumId}`); }} + title={song.album} + >{song.album} + ) : {song.album}} +
+
+ {song.genre ?? '—'} +
+
{fmtDuration(song.duration)}
+
+ ); +} + +/** Column header with the same grid as . Optional — pages can render it above the list. */ +export function SongListHeader() { + const { t } = useTranslation(); + return ( +
+
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackArtist')}
+
{t('albumDetail.trackAlbum')}
+
{t('randomMix.trackGenre')}
+
{t('albumDetail.trackDuration')}
+
+ ); +} + +export default memo(SongRow); diff --git a/src/components/VirtualSongList.tsx b/src/components/VirtualSongList.tsx new file mode 100644 index 00000000..fb155c59 --- /dev/null +++ b/src/components/VirtualSongList.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Search as SearchIcon, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { SubsonicSong, searchSongsPaged } from '../api/subsonic'; +import { ndListSongs } from '../api/navidromeBrowse'; +import SongRow, { SongListHeader } from './SongRow'; + +const PAGE_SIZE = 50; +const SEARCH_DEBOUNCE_MS = 300; +const ROW_HEIGHT = 52; +const PREFETCH_PX = 600; + +/** + * Empty query → Navidrome /api/song sorted by title (no Subsonic equivalent). + * Non-empty → Subsonic search3 (search isn't a browse). + * Either way, returns a SubsonicSong[]; on Navidrome failure we fall back to search3. + */ +async function fetchSongPage(query: string, offset: number): Promise { + if (query !== '') { + return searchSongsPaged(query, PAGE_SIZE, offset); + } + try { + return await ndListSongs(offset, offset + PAGE_SIZE, 'title', 'ASC'); + } catch { + return searchSongsPaged('', PAGE_SIZE, offset); + } +} + +interface Props { + title?: string; + emptyBrowseText?: string; +} + +export default function VirtualSongList({ title, emptyBrowseText }: Props) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [songs, setSongs] = useState([]); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [browseUnsupported, setBrowseUnsupported] = useState(false); + + const scrollParentRef = useRef(null); + const requestSeqRef = useRef(0); + + // Debounce query + useEffect(() => { + const h = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(h); + }, [query]); + + // Reset + first-page fetch on query change. One effect, no dep cascade, + // and a `cancelled` flag so a fast typist doesn't see results from stale queries. + useEffect(() => { + let cancelled = false; + setSongs([]); + setOffset(0); + setHasMore(true); + setBrowseUnsupported(false); + if (scrollParentRef.current) scrollParentRef.current.scrollTop = 0; + + const seq = ++requestSeqRef.current; + setLoading(true); + (async () => { + try { + const page = await fetchSongPage(debouncedQuery, 0); + if (cancelled || seq !== requestSeqRef.current) return; + if (page.length === 0) { + setHasMore(false); + if (debouncedQuery === '') setBrowseUnsupported(true); + } else { + setSongs(page); + setOffset(page.length); + if (page.length < PAGE_SIZE) setHasMore(false); + } + } catch { + if (!cancelled) setHasMore(false); + } finally { + if (!cancelled && seq === requestSeqRef.current) setLoading(false); + } + })(); + + return () => { cancelled = true; }; + }, [debouncedQuery]); + + const loadMore = useCallback(async () => { + if (loading || !hasMore) return; + setLoading(true); + const seq = ++requestSeqRef.current; + try { + const page = await fetchSongPage(debouncedQuery, offset); + if (seq !== requestSeqRef.current) return; + if (page.length === 0) { + setHasMore(false); + } else { + setSongs(prev => { + const seen = new Set(prev.map(s => s.id)); + const merged = [...prev]; + for (const s of page) if (!seen.has(s.id)) merged.push(s); + return merged; + }); + setOffset(o => o + page.length); + if (page.length < PAGE_SIZE) setHasMore(false); + } + } catch { + setHasMore(false); + } finally { + if (seq === requestSeqRef.current) setLoading(false); + } + }, [loading, hasMore, debouncedQuery, offset]); + + // Scroll-based prefetch — uses ref so a stale loadMore can't loop + const loadMoreRef = useRef(loadMore); + useEffect(() => { loadMoreRef.current = loadMore; }, [loadMore]); + + useEffect(() => { + const el = scrollParentRef.current; + if (!el) return; + let rafId = 0; + const onScroll = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - PREFETCH_PX) { + loadMoreRef.current(); + } + }); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + if (rafId) cancelAnimationFrame(rafId); + }; + }, []); + + const virtualizer = useVirtualizer({ + count: songs.length, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 8, + }); + + const totalSize = virtualizer.getTotalSize(); + const showEmptyBrowse = !loading && songs.length === 0 && debouncedQuery === '' && (browseUnsupported || !hasMore); + + return ( +
+ {title &&

{title}

} +
+
+ + setQuery(e.target.value)} + /> + {query && ( + + )} +
+
+ {songs.length > 0 && ( + {t('tracks.count', { count: songs.length })}{hasMore ? '+' : ''} + )} +
+
+ + {showEmptyBrowse ? ( +
+ {emptyBrowseText ?? t('tracks.browseUnsupported')} +
+ ) : ( + <> + +
+
+ {virtualizer.getVirtualItems().map(vi => { + const song = songs[vi.index]; + if (!song) return null; + return ( +
+ +
+ ); + })} +
+ {loading && ( +
+
+ {t('common.loadingMore')} +
+ )} +
+ + )} +
+ ); +} diff --git a/src/config/navItems.ts b/src/config/navItems.ts index f85c40a6..c71540bb 100644 --- a/src/config/navItems.ts +++ b/src/config/navItems.ts @@ -3,6 +3,7 @@ import { Disc3, Users, Music4, Radio, Heart, BarChart3, HelpCircle, Tags, ListMusic, Cast, TrendingUp, FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles, + AudioLines, } from 'lucide-react'; export interface NavItemMeta { @@ -17,6 +18,7 @@ export const ALL_NAV_ITEMS: Record = { mainstage: { icon: Disc3, labelKey: 'sidebar.mainstage', to: '/', section: 'library' }, newReleases: { icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases', section: 'library' }, allAlbums: { icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums', section: 'library' }, + tracks: { icon: AudioLines, labelKey: 'sidebar.tracks', to: '/tracks', section: 'library' }, randomPicker: { icon: Wand2, labelKey: 'sidebar.randomPicker', to: '/random', section: 'library' }, randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random/mix', section: 'library' }, randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' }, diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts new file mode 100644 index 00000000..30003c07 --- /dev/null +++ b/src/hooks/useOrbitGuest.ts @@ -0,0 +1,282 @@ +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, +} from '../utils/orbit'; +import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit'; + +/** + * Orbit — guest-side tick hook. + * + * Mounted at the app shell; only does work when the local store says we're + * a guest in an active session. Two independent timers: + * + * - **State read** (2.5 s): pull the canonical state from the session + * playlist's comment and mirror it into the store. Detect session end + * or own kick and tear down. + * - **Heartbeat** (10 s): refresh the guest's own outbox playlist + * comment so the host's participant sweep sees the user as alive. + * + * Reads are best-effort; a transient Navidrome outage just delays state + * updates by a tick or two. The session continues locally as long as the + * playback engine has the current track loaded. + */ + +const STATE_READ_TICK_MS = 2_500; +const HEARTBEAT_TICK_MS = 10_000; +/** + * Host must be quiet (no state writes) for this long before we treat the + * session as dead and auto-leave. Well above any normal network blip — + * reconnects inside this window are silent. Tuned per user decision: + * manual exits have priority, short reconnects never trigger auto-close. + */ +const HOST_TIMEOUT_MS = 5 * 60_000; + +export function useOrbitGuest(): void { + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId); + const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId); + const sessionId = useOrbitStore(s => s.sessionId); + + const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId; + + /** + * 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` state — a guest joining a + * paused host doesn't auto-start, a guest joining a playing host must + * start. Best-effort; silent on miss. + * + * Seek + state-mirror is applied once the engine reports the target + * track as `isPlaying` (polled up to 2 s), with a final fallback apply + * past the deadline so a loading error doesn't leave the guest stuck + * on a silent pause. + */ + const syncToHost = async (trackId: string, hostState: OrbitState): Promise => { + try { + const song = await getSong(trackId); + if (!song || cancelled) return false; + const track = songToTrack(song); + // Clamp fraction to [0, 0.99] — if the host's positionAt is unusually + // stale, estimateLivePosition can overshoot the track duration and a + // seek past the end would immediately trigger audio:ended. + const calcFraction = () => { + const targetMs = estimateLivePosition(hostState, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); + }; + const applyMirror = (): boolean => { + const p = usePlayerStore.getState(); + if (cancelled || p.currentTrack?.id !== trackId) return false; + p.seek(calcFraction()); + if (hostState.isPlaying && !p.isPlaying) p.resume(); + else if (!hostState.isPlaying && p.isPlaying) p.pause(); + return true; + }; + + const player = usePlayerStore.getState(); + if (player.currentTrack?.id === trackId) { + return applyMirror(); + } + + 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. + return await new Promise(resolve => { + const deadline = Date.now() + 2000; + 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; } + window.setTimeout(poll, 100); + }; + window.setTimeout(poll, 100); + }); + } catch { return false; } + }; + + const pull = async () => { + const state = await readOrbitState(sessionPlaylistId); + if (cancelled) return; + + if (!state) { + // Session playlist is gone — almost always means the host ended the + // session and the `ended:true` write was missed because we polled + // after the subsequent playlist delete. Surface the same modal the + // explicit `state.ended` branch does; the store still holds the last + // known state so the modal can render the host + session name copy. + // Outbox cleanup runs from the modal's OK handler via leaveOrbitSession. + useOrbitStore.getState().setPhase('ended'); + return; + } + + useOrbitStore.getState().setState(state); + + // Auto-leave after prolonged host silence. We keep polling as long as + // state reads succeed (short reconnects are silent), but if the host + // hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the + // session as effectively dead and surface the exit modal. Manual exit + // still works instantly — the bar's X button short-circuits this path. + if (state.positionAt > 0 && (Date.now() - state.positionAt) > HOST_TIMEOUT_MS) { + useOrbitStore.getState().setError('host-timeout'); + return; + } + + // Reconcile pending guest suggestions against the host's *playable* + // queue — NOT `state.queue`, which is the suggestion history (every + // submission lands there immediately, even under manual-approval mode + // where the host hasn't actually accepted the track yet). + // `state.playQueue` is the host's real upcoming queue, so a trackId + // appearing there (or as `currentTrack`) means the host has merged it. + if (useOrbitStore.getState().pendingSuggestions.length > 0) { + const landed = new Set(); + for (const q of (state.playQueue ?? [])) landed.add(q.trackId); + if (state.currentTrack) landed.add(state.currentTrack.trackId); + useOrbitStore.getState().reconcilePendingSuggestions(landed); + } + + // Host signalled session end: surface via `phase`, let the UI handle + // the modal. Outbox cleanup still happens via leaveOrbitSession(). + if (state.ended) { + useOrbitStore.getState().setPhase('ended'); + return; + } + + // Kicked / soft-removed: transition into the error phase with a + // matching errorMessage so the UI can pick the right copy. + const me = useAuthStore.getState().getActiveServer()?.username; + if (me && state.kicked.includes(me)) { + useOrbitStore.getState().setError('kicked'); + return; + } + // Soft-remove: only react to markers strictly newer than our own join + // time, otherwise a stale marker from a prior session-life would + // immediately bounce us out on rejoin. + if (me && state.removed && state.removed.length > 0) { + const joinedAt = useOrbitStore.getState().joinedAt ?? 0; + 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 ONLY if they haven't + // locally diverged. A guest who hit pause should stay paused + // even when the host moves to the next song; otherwise their + // pause button silently un-does itself. If diverged, we just + // advance the anchor so Catch Up stays the opt-in path. + // 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) { + // Initial sync: only record `last` *after* syncToHost actually + // landed. If the first attempt loses the race (engine not ready, + // stale audio state, network blip), a retry ticker below will try + // again every 500 ms until it succeeds. Without this, the first + // failed sync set `last` anyway and the guest was stuck on their + // pre-join state until they clicked Catch Up. + if (hostTrackId) { + const ok = await syncToHost(hostTrackId, state); + if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } else { + lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying }; + } + } else if (last.trackId !== hostTrackId) { + const diverged = 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 + // track the host state so the catch-up prompt stays accurate. + lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } else if (hostTrackId) { + void syncToHost(hostTrackId, state); + lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying }; + } 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 }; + } + }; + + // Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an + // initial sync yet, fall back to the steady cadence once we're anchored. + // Lets a failed first attempt retry quickly without spamming the network + // for the lifetime of the session. + let timer: number | null = null; + const tick = async () => { + timer = null; + await pull(); + if (cancelled) return; + const delay = lastAppliedRef.current === null ? 500 : STATE_READ_TICK_MS; + timer = window.setTimeout(tick, delay); + }; + void tick(); + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + }; + }, [active, sessionPlaylistId]); + + // ── Heartbeat ──────────────────────────────────────────────────────── + useEffect(() => { + if (!active || !outboxPlaylistId || !sessionId) return; + const me = useAuthStore.getState().getActiveServer()?.username; + if (!me) return; + const outboxName = orbitOutboxPlaylistName(sessionId, me); + + const beat = async () => { + try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); } + catch { /* best-effort */ } + }; + void beat(); + + const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS); + return () => window.clearInterval(id); + }, [active, outboxPlaylistId, sessionId]); +} diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts new file mode 100644 index 00000000..4a2c0daa --- /dev/null +++ b/src/hooks/useOrbitHost.ts @@ -0,0 +1,235 @@ +import { useEffect, useRef } from 'react'; +import { useOrbitStore } from '../store/orbitStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { getSong } from '../api/subsonic'; +import { + writeOrbitState, + writeOrbitHeartbeat, + sweepGuestOutboxes, + applyOutboxSnapshotsToState, + maybeShuffleQueue, + effectiveShuffleIntervalMs, + suggestionKey, +} from '../utils/orbit'; +import { + orbitOutboxPlaylistName, + ORBIT_PLAY_QUEUE_LIMIT, + type OrbitState, + type OrbitQueueItem, +} from '../api/orbit'; +import { showToast } from '../utils/toast'; +import i18n from '../i18n'; + +/** + * Orbit — host-side tick hook. + * + * Mounted once at the app shell level; only does work when the local store + * says we're the host of an active session. Two independent timers: + * + * - **State tick** (2.5 s): snapshot isPlaying + position + current track + * from the player store, patch the local OrbitState, push to the + * session playlist's comment. + * - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's + * comment with a fresh timestamp so the later-added participant + * pipeline can treat the host symmetrically. + * + * Writes are best-effort — a transient Navidrome outage just means guests + * see stale state for a tick or two and catch up on the next write. + * Phase 2 does not yet consume anything from guests. + */ + +const STATE_TICK_MS = 2_500; +const HEARTBEAT_TICK_MS = 10_000; + +export function useOrbitHost(): void { + const role = useOrbitStore(s => s.role); + const phase = useOrbitStore(s => s.phase); + const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId); + const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId); + const sessionId = useOrbitStore(s => s.sessionId); + + // Refs hold the last values we used to build the patch — cheap to + // recompute against, no need to subscribe to every playerStore tick. + const lastPushedAtRef = useRef(0); + + const active = role === 'host' && phase === 'active' && !!sessionPlaylistId; + + useEffect(() => { + if (!active || !sessionPlaylistId) return; + + const snapshotPlayerPatch = (hostUsername: string): Partial => { + const p = usePlayerStore.getState(); + const now = Date.now(); + return { + isPlaying: p.isPlaying, + positionMs: Math.round((p.currentTime ?? 0) * 1000), + positionAt: now, + currentTrack: p.currentTrack + ? { + trackId: p.currentTrack.id, + // Locally-initiated plays are marked as authored by the host. + // Guest-suggested tracks that later become `currentTrack` will + // carry their original attribution because the queue-consume + // flow keeps the `addedBy` from the guest's outbox. + addedBy: hostUsername, + addedAt: now, + } + : null, + }; + }; + + const pushState = async () => { + const store = useOrbitStore.getState(); + const base = store.state; + if (!base) return; + + // 1) Sweep every guest outbox: new suggestions + fresh heartbeats. + let afterSweep = base; + try { + const snaps = await sweepGuestOutboxes(base.sid, base.host); + afterSweep = applyOutboxSnapshotsToState(base, snaps); + } catch { /* best-effort; keep old participants and queue */ } + + // 2) Merge newly-suggested items into the host's local play queue so + // guest suggestions actually start playing alongside host-chosen + // tracks. Must happen BEFORE the shuffle step so the merge decision + // tracks `addedAt` (immutable) rather than list position. + await mergeNewSuggestionsIntoQueue(afterSweep.queue); + + // 3) Shuffle check: + // a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing + // suggestion list). It also bumps `lastShuffle` even when the list + // is too small to reorder — that's the authoritative 15-min marker. + // b) In parallel, we shuffle the *host's* upcoming play queue so the + // mix the guests hear actually changes. `autoShuffle=false` skips + // both. + const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false + && (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep)); + const afterShuffle = maybeShuffleQueue(afterSweep); + if (shouldShuffleNow) { + const before = usePlayerStore.getState().queue.length; + usePlayerStore.getState().shuffleUpcomingQueue(); + if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info'); + } + + // 4) Overlay the host's live playback snapshot. + const playerLive = usePlayerStore.getState(); + const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1); + // Map track id → original suggester (if any). State's `queue` carries + // every suggestion we've ever seen this session, so it's the right + // attribution source even after the track has been merged into the + // host's player queue. + const suggesterByTrack = new Map(); + for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy); + const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({ + trackId: t.id, + addedBy: suggesterByTrack.get(t.id) ?? base.host, + })); + const next: OrbitState = { + ...afterShuffle, + ...snapshotPlayerPatch(base.host), + playQueue, + playQueueTotal: upcoming.length, + }; + + // 5) Commit locally + push remote. + useOrbitStore.getState().setState(next); + try { + await writeOrbitState(sessionPlaylistId, next); + lastPushedAtRef.current = Date.now(); + } catch { /* best-effort; next tick retries */ } + }; + + /** + * Resolve each not-yet-merged suggestion via `getSong` and append to the + * player queue. Records a toast per successful append so the host notices + * guest activity. Safe to call every tick — the set filter keeps it idempotent. + */ + const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => { + // Opt-out: host turned auto-approve off. Items still accumulate in + // `OrbitState.queue` and show up in the guest view / approval list — + // they just don't flow into the host's actual play queue yet. + const store = useOrbitStore.getState(); + const settings = store.state?.settings; + if (settings && settings.autoApprove === false) return; + + // Host-authored items are enqueued directly by `hostEnqueueToOrbit` and + // must not flow through the merge pipeline again — otherwise the tick + // would duplicate the track into the upcoming queue. Declined items + // stay out too; merged items are the existing dedup anchor. + const hostUser = store.state?.host; + const mergedKeys = new Set(store.mergedSuggestionKeys); + const declinedKeys = new Set(store.declinedSuggestionKeys); + const pending = items.filter(q => + q.addedBy !== hostUser + && !mergedKeys.has(suggestionKey(q)) + && !declinedKeys.has(suggestionKey(q)) + ); + if (pending.length === 0) return; + + // Resolve in parallel — Navidrome is fine with concurrent getSong calls. + const resolved = await Promise.all(pending.map(async q => { + try { + const song = await getSong(q.trackId); + return song ? { q, track: songToTrack(song) } : null; + } catch { + return null; + } + })); + + const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType } => r !== null); + const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q))); + if (toEnqueue.length === 0) { + // Mark the failed lookups as seen anyway so we don't keep retrying + // every tick for a track the server can't serve. + markAllAsMerged(); + return; + } + + // Sprinkle each track at a random spot inside the upcoming range so + // guest suggestions interleave with host-picked tracks rather than + // pile up at the end (where they'd never play until the 15-min shuffle). + const player = usePlayerStore.getState(); + for (const { track } of toEnqueue) { + const live = usePlayerStore.getState(); + const from = Math.max(0, live.queueIndex + 1); + const to = live.queue.length; + const span = Math.max(1, to - from + 1); + const pos = from + Math.floor(Math.random() * span); + player.enqueueAt([track], pos); + } + markAllAsMerged(); + + // Friendly nudge per sweep, not per track — bundled toast if >1. + if (toEnqueue.length === 1) { + const { q, track } = toEnqueue[0]; + showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info'); + } else { + showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info'); + } + }; + + // Immediate push on mount so guests see fresh state without waiting + // a full tick after the host comes online. + void pushState(); + + const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS); + return () => window.clearInterval(id); + }, [active, sessionPlaylistId]); + + useEffect(() => { + if (!active || !outboxPlaylistId || !sessionId) return; + const server = useOrbitStore.getState().state?.host; + if (!server) return; + const outboxName = orbitOutboxPlaylistName(sessionId, server); + + const pushHeartbeat = async () => { + try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); } + catch { /* best-effort */ } + }; + void pushHeartbeat(); + + const id = window.setInterval(() => { void pushHeartbeat(); }, HEARTBEAT_TICK_MS); + return () => window.clearInterval(id); + }, [active, outboxPlaylistId, sessionId]); +} diff --git a/src/hooks/useOrbitSongRowBehavior.ts b/src/hooks/useOrbitSongRowBehavior.ts new file mode 100644 index 00000000..c439feb1 --- /dev/null +++ b/src/hooks/useOrbitSongRowBehavior.ts @@ -0,0 +1,73 @@ +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + suggestOrbitTrack, + hostEnqueueToOrbit, + evaluateOrbitSuggestGate, + OrbitSuggestBlockedError, +} from '../utils/orbit'; +import { showToast } from '../utils/toast'; + +/** + * Shared behaviour for song rows that in "normal mode" swallow a full list + * into the queue on single-click (AlbumDetail, PlaylistDetail, Favorites, + * ArtistDetail top-songs, SearchResults, RandomMix, AdvancedSearch). + * + * In an active Orbit session this is too destructive — the list would + * propagate to every guest's player. Instead: + * + * - `queueHint()` — show a toast telling the user to double-click. + * Safe to call on every single-click; 220 ms debounce + * suppresses the pileup that browsers emit before a + * dblclick fires. + * - `addTrackToOrbit(songId)` — cancel any pending hint and add just that + * one track: suggestOrbitTrack for guests, + * hostEnqueueToOrbit for the host. + * + * `orbitActive` is the gate — when false, callers should skip the hint and + * run their original bulk-play path unchanged. + */ +export function useOrbitSongRowBehavior() { + const { t } = useTranslation(); + const orbitRole = useOrbitStore(s => s.role); + const orbitActive = orbitRole === 'host' || orbitRole === 'guest'; + const clickTimerRef = useRef(null); + + const queueHint = useCallback(() => { + if (clickTimerRef.current !== null) return; + clickTimerRef.current = window.setTimeout(() => { + clickTimerRef.current = null; + showToast(t('albumDetail.orbitDoubleClickHint'), 2400, 'info'); + }, 220); + }, [t]); + + const addTrackToOrbit = useCallback((songId: string) => { + if (clickTimerRef.current !== null) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + if (orbitRole === 'guest') { + const gate = evaluateOrbitSuggestGate(); + if (!gate.allowed && gate.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + return; + } + suggestOrbitTrack(songId) + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(err => { + if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { + showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); + } else { + showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); + } + }); + } else if (orbitRole === 'host') { + hostEnqueueToOrbit(songId) + .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); + } + }, [orbitRole, t]); + + return { orbitActive, queueHint, addTrackToOrbit }; +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 9aee350e..1e026994 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -21,6 +21,7 @@ export const deTranslation = { cancelDownload: 'Download abbrechen', offlineLibrary: 'Offline-Bibliothek', genres: 'Genres', + tracks: 'Titel', playlists: 'Playlists', mostPlayed: 'Meistgehört', radio: 'Internetradio', @@ -40,6 +41,7 @@ export const deTranslation = { mostPlayed: 'Meistgehört', recentlyPlayed: 'Kürzlich gespielt', discover: 'Entdecken', + discoverSongs: 'Titel entdecken', loadMore: 'Mehr laden', discoverMore: 'Mehr entdecken', discoverArtists: 'Künstler entdecken', @@ -178,6 +180,7 @@ export const deTranslation = { }, albumDetail: { back: 'Zurück', + orbitDoubleClickHint: 'Doppelklick fügt diesen Titel zur Orbit-Queue hinzu', playAll: 'Alle abspielen', shareAlbum: 'Album teilen', enqueue: 'Einreihen', @@ -377,6 +380,20 @@ export const deTranslation = { offlineQueuing: '{{count}} Album(s) für Offline einreihen…', offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden', }, + tracks: { + title: 'Titel', + subtitle: 'Stöbern. Suchen. Entdecken.', + heroEyebrow: 'Titel des Moments', + heroReroll: 'Anderen wählen', + playSong: 'Abspielen', + enqueueSong: 'Zur Warteschlange', + railRandom: 'Zufallsauswahl', + browseTitle: 'Alle Titel durchstöbern', + browseUnsupported: 'Dieser Server listet nicht die ganze Bibliothek auf einmal. Nutze die Suche oben, um bestimmte Titel zu finden.', + searchPlaceholder: 'Titel, Künstler oder Album suchen…', + count_one: '{{count}} Titel', + count_other: '{{count}} Titel', + }, artists: { title: 'Künstler', search: 'Suchen…', @@ -670,6 +687,8 @@ export const deTranslation = { hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.", showArtistImages: 'Künstlerbilder anzeigen', showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.', + showOrbitTrigger: '„Orbit" im Header zeigen', + showOrbitTriggerDesc: 'Der Kopfzeilen-Button zum Starten oder Beitreten einer gemeinsamen Hör-Session. Ausblenden, wenn du Orbit nicht nutzt — hier lässt er sich jederzeit wieder einschalten.', showTrayIcon: 'Tray-Icon anzeigen', showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.', minimizeToTray: 'Im Tray minimieren', @@ -1489,4 +1508,183 @@ export const deTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Zufallsalben', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Gemeinsame Hör-Session starten oder beitreten', + launchCreate: 'Session erstellen', + launchJoin: 'Session beitreten', + launchHelp: 'Wie funktioniert das?', + launchHelpSoon: 'Kommt bald', + joinModalTitle: 'Einer Orbit-Session beitreten', + joinModalSub: 'Füge den Einladungslink ein, den dir der Host geschickt hat.', + joinModalLinkLabel: 'Einladungslink', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Akzeptiert jeden gültigen Orbit-Link. Der Link muss zu dem Server gehören, auf dem du gerade angemeldet bist.', + joinModalPasteTooltip: 'Aus Zwischenablage einfügen', + joinModalSubmit: 'Beitreten', + joinModalBusy: 'Beitreten…', + joinErrEmpty: 'Bitte Einladungslink einfügen.', + joinErrInvalid: 'Das sieht nicht nach einem Orbit-Einladungslink aus.', + closeAria: 'Schließen', + heroTitlePrefix: 'Gemeinsam hören mit', + heroTitleBrand: 'Orbit', + heroSub: 'Starte eine Session — andere Nutzer auf {{server}} hören im Takt mit dir und können Songs vorschlagen.', + fallbackServer: 'diesem Server', + tipLan: 'Du bist aktuell mit einer Heimnetz-Adresse verbunden. Gäste außerhalb deines Heimnetzes können so nicht beitreten — wechsle vor dem Start in den Servereinstellungen zu einer öffentlichen Adresse.', + tipRemote: 'Damit Gäste außerhalb deines Heimnetzes beitreten können, muss deine Serveradresse öffentlich erreichbar sein. Ist dein Server nur intern verfügbar, wechsle vor dem Start zu einer öffentlichen Adresse.', + labelName: 'Sessionname', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Neuer Vorschlag', + reshuffleAria: 'Neuen Namen vorschlagen', + helperName: 'So taucht die Session für deine Gäste auf — Vorschlag übernehmen oder eigenen eintippen.', + labelMax: 'Maximale Gäste', + helperMax: 'Du zählst nicht mit — das ist nur die Obergrenze für beitretende Gäste.', + labelLink: 'Einladungslink', + tooltipCopied: 'Kopiert', + tooltipCopy: 'Kopieren', + ariaCopyLink: 'Link kopieren', + helperLink: 'Schicke diesen Link deinen Gästen. Sie fügen ihn mit Strg+V irgendwo in Psysonic ein.', + labelClearQueue: 'Eigene Warteschlange vorher leeren', + helperClearQueue: 'Mit leerer Warteschlange in die Session starten. Aus: bereits aufgereihte Titel bleiben und werden mit den Gästen geteilt.', + btnCancel: 'Abbrechen', + btnStarting: 'Starte…', + btnStart: 'Orbit starten', + btnCopyAndStart: 'Link kopieren & starten', + errNameRequired: 'Bitte gib deiner Session einen Namen.', + errStartFailed: 'Konnte nicht starten.', + hostLabel: 'Gastgeber: {{name}}', + participantsTooltip: 'Teilnehmer', + settingsTooltip: 'Session-Einstellungen', + shareTooltip: 'Einladungslink teilen', + shuffleLabel: 'Warteschlange wird neu gemischt in', + catchUpLabel: 'aufholen', + catchUpTooltip: 'Zur aktuellen Host-Position springen', + hostAway: 'Host offline', + hostOnline: 'Host online', + endTooltip: 'Session beenden', + leaveTooltip: 'Session verlassen', + helpTooltip: 'Wie Orbit funktioniert', + helpTitle: 'Wie Orbit funktioniert', + helpIntro: 'Orbit macht aus Psysonic einen gemeinsamen Hörraum. Der Host bestimmt die Musik; Gäste hören synchron mit und können Songs vorschlagen.', + helpHostOnly: '(Host)', + helpSec1Title: 'Was ist Orbit?', + helpSec1Body: 'Ein „Listen together"-Modus — der Host steuert die Wiedergabe, die Gäste hören mit. Alles läuft über Playlists auf deinem eigenen Navidrome-Server; keine externe Infrastruktur.', + helpSec2Title: 'Host und Gäste', + helpSec2Body: 'Der Host steuert die Wiedergabe (Play / Pause / Skip / Seek) und entscheidet, wann die Session endet. Gäste folgen der Wiedergabe des Hosts, können Songs vorschlagen und jederzeit verlassen oder wieder beitreten. Nur der Host kann Teilnehmer kicken oder dauerhaft bannen.', + helpSec2WarnHead: 'Wichtig: getrennte Accounts.', + helpSec2WarnBody: 'Jeder Teilnehmer braucht einen eigenen Navidrome-Account. Benutzen Host und Gast denselben User, kollidieren ihre Einreichungen und der Host sieht keine Vorschläge vom Gast.', + helpSec3Title: 'Session starten und einladen', + helpSec3Body: 'Klick auf den „Orbit"-Button → Session erstellen → im Start-Modal siehst du einen fertigen Einladungslink und kannst optional die aktuelle Warteschlange leeren. Link teilen wie du magst. Während die Session läuft, kopiert der Share-Button in der Session-Leiste oben jederzeit den Link erneut.', + helpSec4Title: 'Beitreten', + helpSec4Body: 'Einladungslink irgendwo in Psysonic mit Ctrl+V / Cmd+V einfügen — der Beitritt-Dialog erscheint automatisch. Alternativ: „Orbit" → Session beitreten → Link ins Feld pasten. Wenn der Link auf einen Server zeigt, für den du einen Account hast, wechselt Psysonic automatisch. Bei mehreren Accounts auf demselben Server fragt ein kleiner Picker, welcher genutzt werden soll.', + helpSec5Title: 'Songs vorschlagen', + helpSec5Body: 'In jeder Song-Liste: Doppelklick auf eine Zeile, oder Rechtsklick → „Zur Orbit-Session hinzufügen". Ein einfacher Klick zeigt nur einen Hinweis, statt das ganze Album in die geteilte Queue zu werfen — das ist bewusst so. Explizite Bulk-Aktionen wie „Play All" oder „Play Album" fragen zuerst nach und hängen dann an (ersetzen nie).', + helpSec6Title: 'Die geteilte Warteschlange', + helpSec6Body: 'Die Queue zeigt die kommenden Titel des Hosts — für alle sichtbar. Neu hinzugefügte Bulk-Inhalte werden immer angehängt, damit Gast-Vorschläge nicht verloren gehen. Auto-Shuffle mischt die Queue periodisch neu (konfigurierbar: 1 / 5 / 10 / 15 / 30 Min.). Wenn deine Wiedergabe vom Host abweicht, bringt dich der „Aufholen"-Button in der Session-Leiste zurück zur Live-Position des Hosts.', + helpSec7Title: 'Freigaben', + helpSec7Body: 'Standardmäßig müssen Vorschläge von Gästen manuell freigegeben werden — sie erscheinen in einer „Freigabe"-Leiste oben im Queue-Panel mit Annehmen/Ablehnen-Buttons. In den Session-Einstellungen lässt sich Auto-Übernehmen aktivieren, dann landen alle Vorschläge direkt in der Queue.', + helpSec8Title: 'Teilnehmer und Einstellungen', + helpSec8Body: 'Das Einstellungs-Icon in der Session-Leiste öffnet Shuffle-Takt, Auto-Übernehmen und die „Jetzt mischen"-Abkürzung. Klick auf die Teilnehmerzahl zeigt wer gerade dabei ist — Kick (kann über den Link wieder beitreten) oder Bann (für die Session gesperrt). Gäste sehen die Teilnehmerliste ebenfalls, aber nur lesend.', + helpSec9Title: 'Session beenden', + helpSec9Body: 'Host-X → Bestätigungsdialog → Session endet für alle und die Server-Playlists werden automatisch aufgeräumt. Gast-X → der Gast verlässt, die Session läuft weiter. Wenn der Host 5 Minuten lang nichts mehr sendet, verlässt der Gast die Session automatisch mit einer Meldung; kürzere Reconnects innerhalb dieser Zeit bleiben unsichtbar.', + participantsInviteLabel: 'Einladungslink', + participantsCountLabel: '{{count}} in Session', + participantsHost: 'Gastgeber', + participantsEmpty: 'Noch keine Gäste', + participantsKickTooltip: 'Aus Session entfernen', + participantsKickAria: '{{user}} entfernen', + participantsRemoveTooltip: 'Aus Session entfernen', + participantsRemoveAria: '{{user}} aus dieser Session entfernen', + participantsBanTooltip: 'Permanent bannen', + participantsBanAria: '{{user}} permanent bannen', + participantsMuteTooltip: 'Vorschläge sperren', + participantsUnmuteTooltip: 'Vorschläge erlauben', + participantsMuteAria: 'Vorschläge von {{user}} sperren', + participantsUnmuteAria: 'Vorschläge von {{user}} wieder erlauben', + confirmRemoveTitle: 'Aus Session entfernen?', + confirmRemoveBody: '{{user}} wird aus der Session geworfen, kann aber jederzeit über deinen Einladungslink wieder beitreten.', + confirmRemoveConfirm: 'Entfernen', + confirmBanTitle: 'Permanent bannen?', + confirmBanBody: '{{user}} wird entfernt und kann dieser Session nicht wieder beitreten. Für die Lebensdauer der Session nicht rückgängig zu machen.', + confirmBanConfirm: 'Bannen', + confirmCancel: 'Abbrechen', + confirmJoinTitle: 'Orbit-Session beitreten?', + confirmJoinBody: '{{host}} lädt dich zu „{{name}}" ein. Möchtest du der Session beitreten?', + confirmJoinConfirm: 'Beitreten', + confirmLeaveTitle: 'Session verlassen?', + confirmLeaveBody: '„{{name}}" wirklich verlassen? Du kannst jederzeit über {{host}}s Einladungslink wieder beitreten.', + confirmLeaveConfirm: 'Verlassen', + confirmEndTitle: 'Session für alle beenden?', + confirmEndBody: '„{{name}}" wird für alle Teilnehmer geschlossen. Die Session-Playlists werden automatisch aufgeräumt.', + confirmEndConfirm: 'Session beenden', + invalidLinkTitle: 'Einladungslink ist nicht mehr gültig', + invalidLinkBody: 'Diese Orbit-Session existiert nicht mehr oder wurde bereits beendet. Bitte den Gastgeber um einen neuen Einladungslink.', + settingsTitle: 'Session-Einstellungen', + settingAutoApprove: 'Vorschläge automatisch übernehmen', + settingAutoApproveHint: 'Neue Gäste-Vorschläge landen sofort in deiner Warteschlange. Aus: sie bleiben in der Session-Liste, du musst sie später manuell übernehmen.', + settingAutoShuffle: 'Automatisch mischen', + settingAutoShuffleHint: 'Periodischer Fisher-Yates-Shuffle der kommenden Warteschlange. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.', + settingShuffleInterval: 'Alle', + settingShuffleIntervalHint: 'Wie oft die kommende Warteschlange während der Session neu gemischt wird.', + suggestBlockedMuted: 'Der Host hat dich für diese Session gesperrt — keine Vorschläge möglich.', + settingShuffleIntervalValue_one: '{{count}} Min.', + settingShuffleIntervalValue_other: '{{count}} Min.', + settingShuffleNow: 'Jetzt mischen', + toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen', + toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange', + toastShuffled: 'Warteschlange gemischt', + toastJoined: 'Session beigetreten', + toastLoginFirst: 'Erst anmelden, um einer Session beizutreten', + toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein', + toastNoAccountForServer: 'Du hast keinen Zugang zu {{url}}. Bitte den Host um einen Einladungslink.', + toastSwitchFailed: 'Wechsel zu {{url}} fehlgeschlagen', + accountPickerTitle: 'Welcher Account?', + accountPickerSub: 'Du hast mehrere Accounts für {{url}}. Wähle aus, mit welchem du der Session beitreten willst.', + toastJoinFail: 'Session konnte nicht beigetreten werden', + joinErrNotFound: 'Session nicht gefunden', + joinErrEnded: 'Session wurde beendet', + joinErrFull: 'Session ist voll', + joinErrKicked: 'Du kannst dieser Session nicht mehr beitreten', + joinErrNoUser: 'Kein aktiver Server', + joinErrServerError: 'Beitritt fehlgeschlagen', + ctxAddToSession: 'Zur Orbit-Session hinzufügen', + ctxSuggestedToast: 'An Session vorgeschlagen', + ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten', + ctxAddToSessionHost: 'Zur Orbit-Session hinzufügen', + ctxAddedHostToast: 'Zur Session hinzugefügt', + ctxAddHostFailed: 'Hinzufügen fehlgeschlagen', + bulkConfirmTitle: 'Wirklich alles in die Orbit-Queue?', + bulkConfirmBody: 'Das würde {{count}} Titel auf einmal in die geteilte Queue werfen. Für deine Gäste ist das eine Menge. Trotzdem hinzufügen?', + bulkConfirmYes: 'Alle hinzufügen', + bulkConfirmNo: 'Abbrechen', + guestLive: 'Live', + guestPlaying: 'Läuft gerade', + guestPaused: 'Pausiert', + guestLoading: 'Lade…', + guestSuggestions: 'Vorschläge', + guestUpNext: 'Als Nächstes', + guestUpNextEmpty: 'Nichts in der Warteschlange. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen", um etwas vorzuschlagen.', + guestPendingTitle: 'Wartet auf Host', + guestPendingHint: 'Vorgeschlagen — taucht in der Warteschlange auf, sobald der Host sie übernimmt.', + approvalTitle: 'Zur Freigabe', + approvalFrom: 'Vorgeschlagen von {{user}}', + approvalAccept: 'Übernehmen', + approvalDecline: 'Ablehnen', + guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange', + guestSubmitter: 'von {{user}}', + queueAddedByHost: 'Vom Host hinzugefügt', + queueAddedByYou: 'Von dir hinzugefügt', + queueAddedByUser: 'Hinzugefügt von {{user}}', + guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".', + guestFooter: 'Die Wiedergabe wird vom Gastgeber gesteuert — du kannst die Liste nicht ändern.', + exitKickedTitle: 'Du wurdest aus der Session gebannt', + exitRemovedTitle: 'Du wurdest aus der Session entfernt', + exitEndedTitle: 'Der Gastgeber hat die Session beendet', + exitHostTimeoutTitle: 'Host nicht mehr erreichbar', + exitHostTimeoutBody: '{{host}} hat sich länger nicht mehr gemeldet — „{{name}}" wurde auf deiner Seite beendet. Über den Einladungslink kannst du jederzeit wieder beitreten.', + exitKickedBody: '{{host}} hat dich aus „{{name}}" gebannt. Beitritt nicht mehr möglich.', + exitRemovedBody: '{{host}} hat dich aus „{{name}}" entfernt. Du kannst jederzeit über den Einladungslink wieder beitreten.', + exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.', + exitOk: 'OK', + }, }; diff --git a/src/locales/en.ts b/src/locales/en.ts index 87cf2fef..bf61a1f1 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -22,6 +22,7 @@ export const enTranslation = { cancelDownload: 'Cancel download', offlineLibrary: 'Offline Library', genres: 'Genres', + tracks: 'Tracks', playlists: 'Playlists', smartPlaylists: 'Smart Playlists', mostPlayed: 'Most Played', @@ -42,6 +43,7 @@ export const enTranslation = { mostPlayed: 'Most Played', recentlyPlayed: 'Recently Played', discover: 'Discover', + discoverSongs: 'Discover Songs', loadMore: 'Load More', discoverMore: 'Discover More', discoverArtists: 'Discover Artists', @@ -180,6 +182,7 @@ export const enTranslation = { }, albumDetail: { back: 'Back', + orbitDoubleClickHint: 'Double-click to add this track to the Orbit queue', playAll: 'Play All', shareAlbum: 'Share album', enqueue: 'Enqueue', @@ -379,6 +382,20 @@ export const enTranslation = { offlineQueuing: 'Queuing {{count}} album(s) for offline…', offlineFailed: 'Failed to add {{name}} offline', }, + tracks: { + title: 'Tracks', + subtitle: 'Browse. Search. Discover.', + heroEyebrow: 'Track of the moment', + heroReroll: 'Pick another', + playSong: 'Play', + enqueueSong: 'Add to queue', + railRandom: 'Random Pick', + browseTitle: 'Browse all tracks', + browseUnsupported: "This server doesn't list the whole library at once. Use the search above to find specific tracks.", + searchPlaceholder: 'Find a track by title, artist or album…', + count_one: '{{count}} track', + count_other: '{{count}} tracks', + }, artists: { title: 'Artists', search: 'Search…', @@ -673,6 +690,8 @@ export const enTranslation = { hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).", showArtistImages: 'Show Artist Images', showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.', + showOrbitTrigger: 'Show "Orbit" in the header', + showOrbitTriggerDesc: 'The top-bar trigger for starting or joining a shared listening session. Hide it if you don\'t use Orbit — you can turn it back on here.', showTrayIcon: 'Show Tray Icon', showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.', minimizeToTray: 'Minimize to Tray', @@ -1493,4 +1512,183 @@ export const enTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Random Albums', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Start or join a shared listening session', + launchCreate: 'Create a session', + launchJoin: 'Join a session', + launchHelp: 'How does this work?', + launchHelpSoon: 'Coming soon', + joinModalTitle: 'Join an Orbit session', + joinModalSub: 'Paste the invite link your host sent you.', + joinModalLinkLabel: 'Invite link', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Works with any valid Orbit link. Must point to the server you\'re currently signed into.', + joinModalPasteTooltip: 'Paste from clipboard', + joinModalSubmit: 'Join', + joinModalBusy: 'Joining…', + joinErrEmpty: 'Please paste an invite link.', + joinErrInvalid: 'That doesn\'t look like an Orbit invite link.', + closeAria: 'Close', + heroTitlePrefix: 'Listen together with', + heroTitleBrand: 'Orbit', + heroSub: 'Start a session — other users on {{server}} will listen along and can suggest tracks.', + fallbackServer: 'this server', + tipLan: "You're currently connected via a home-network address. Guests outside your network can't join — switch to a public server address in settings before you start.", + tipRemote: 'For guests outside your home network to join, your server address has to be publicly reachable. If your server is internal only, switch before you start.', + labelName: 'Session name', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'New suggestion', + reshuffleAria: 'Suggest a new name', + helperName: 'How the session shows up to your guests — keep it or type your own.', + labelMax: 'Max guests', + helperMax: "You don't count — this only caps incoming guests.", + labelLink: 'Invite link', + tooltipCopied: 'Copied', + tooltipCopy: 'Copy', + ariaCopyLink: 'Copy invite link', + helperLink: 'Send this link to your guests. They can paste it anywhere in Psysonic with Ctrl+V.', + labelClearQueue: 'Clear my queue first', + helperClearQueue: 'Start the session with a fresh, empty queue. Off: the tracks you already have queued stay and get shared with guests.', + btnCancel: 'Cancel', + btnStarting: 'Starting…', + btnStart: 'Start Orbit', + btnCopyAndStart: 'Copy link & start', + errNameRequired: 'Please give your session a name.', + errStartFailed: "Couldn't start.", + hostLabel: 'Host: {{name}}', + participantsTooltip: 'Participants', + settingsTooltip: 'Session settings', + shareTooltip: 'Share invite link', + shuffleLabel: 'Queue reshuffles in', + catchUpLabel: 'catch up', + catchUpTooltip: "Jump to the host's current position", + hostAway: 'Host offline', + hostOnline: 'Host online', + endTooltip: 'End session', + leaveTooltip: 'Leave session', + helpTooltip: 'How Orbit works', + helpTitle: 'How Orbit works', + helpIntro: 'Orbit turns Psysonic into a shared listening room. A host picks the music; guests listen in sync and can suggest tracks.', + helpHostOnly: '(host)', + helpSec1Title: 'What is Orbit?', + helpSec1Body: "A \"listen together\" mode — the host drives playback, guests tune in. Everything runs through playlists on your own Navidrome server; no external infrastructure.", + helpSec2Title: 'Host and guests', + helpSec2Body: 'Host controls playback (play / pause / skip / seek) and decides when the session ends. Guests follow the host\'s playback, can suggest tracks, and leave or re-join any time. Only the host can kick or permanently ban a participant.', + helpSec2WarnHead: 'Important: separate accounts.', + helpSec2WarnBody: "Every participant needs their own Navidrome account. If host and guest sign in with the same user their submissions collide and the host never sees the guest's suggestions.", + helpSec3Title: 'Starting and inviting', + helpSec3Body: 'Click the "Orbit" button → Create a session → the start modal shows a ready-to-copy invite link and lets you optionally clear your current queue. Share that link however you like. While a session is running, the share button in the session bar at the top copies the link again at any time.', + helpSec4Title: 'Joining', + helpSec4Body: 'Paste the invite link anywhere in Psysonic with Ctrl+V / Cmd+V — the join prompt pops up automatically. Alternatively: "Orbit" → Join a session → paste into the field. If the link points to a server you have an account on, Psysonic switches for you. When you have more than one account on that server, a small picker asks which to use.', + helpSec5Title: 'Suggesting tracks', + helpSec5Body: 'In any song list: double-click a row, or right-click → "Add to Orbit session". A single click shows a hint instead of dumping the whole album into the shared queue — that\'s intentional. Explicit bulk actions like "Play All" or "Play Album" ask for confirmation first and then append (never replace).', + helpSec6Title: 'The shared queue', + helpSec6Body: "The queue shows the host's upcoming tracks — visible to everyone. Incoming bulk additions are always appended, so guest suggestions aren't lost. Auto-shuffle reorders the queue periodically (configurable: 1 / 5 / 10 / 15 / 30 min). If your playback drifts away from the host, the \"Catch up\" button in the session bar jumps you back to the host's live position.", + helpSec7Title: 'Approvals', + helpSec7Body: 'By default, guest suggestions need manual approval — they appear in an "Approvals" strip at the top of the queue panel with accept / decline buttons. Auto-approve can be turned on in the session settings so everything lands straight away.', + helpSec8Title: 'Participants and settings', + helpSec8Body: 'Open the settings icon in the session bar for shuffle cadence, auto-approve, and the "Shuffle now" shortcut. Click the participants count to see who\'s connected — kick (can re-join via the link) or ban (locked out for the session). Guests see the participant list too, but read-only.', + helpSec9Title: 'Ending the session', + helpSec9Body: "Host X → confirm dialog → session closes for everyone and the server playlists are cleaned up automatically. Guest X → the guest leaves, the session keeps running. If the host goes quiet for 5 minutes the guest auto-leaves with a notice; short reconnects within that window are invisible.", + participantsInviteLabel: 'Invite link', + participantsCountLabel: '{{count}} in session', + participantsHost: 'host', + participantsEmpty: 'No guests yet', + participantsKickTooltip: 'Remove from session', + participantsKickAria: 'Remove {{user}}', + participantsRemoveTooltip: 'Remove from session', + participantsRemoveAria: 'Remove {{user}} from this session', + participantsBanTooltip: 'Ban permanently', + participantsBanAria: 'Permanently ban {{user}}', + participantsMuteTooltip: 'Mute suggestions', + participantsUnmuteTooltip: 'Allow suggestions', + participantsMuteAria: 'Mute suggestions from {{user}}', + participantsUnmuteAria: 'Allow suggestions from {{user}}', + confirmRemoveTitle: 'Remove from session?', + confirmRemoveBody: '{{user}} will be dropped from the session, but can re-join via your invite link.', + confirmRemoveConfirm: 'Remove', + confirmBanTitle: 'Permanently ban?', + confirmBanBody: '{{user}} will be removed and blocked from re-joining this session. This can\'t be undone for the lifetime of the session.', + confirmBanConfirm: 'Ban', + confirmCancel: 'Cancel', + confirmJoinTitle: 'Join Orbit session?', + confirmJoinBody: '{{host}} invited you to "{{name}}". Join the session?', + confirmJoinConfirm: 'Join', + confirmLeaveTitle: 'Leave session?', + confirmLeaveBody: 'Leave "{{name}}"? You can re-join via {{host}}\'s invite link any time.', + confirmLeaveConfirm: 'Leave', + confirmEndTitle: 'End session for everyone?', + confirmEndBody: '"{{name}}" will close for all participants. The session\'s playlists are cleaned up automatically.', + confirmEndConfirm: 'End session', + invalidLinkTitle: 'Invite link is no longer valid', + invalidLinkBody: 'This Orbit session doesn\'t exist any more or has already ended. Ask the host for a fresh invite link.', + settingsTitle: 'Session settings', + settingAutoApprove: 'Auto-approve suggestions', + settingAutoApproveHint: "Guest suggestions land in your queue instantly. Off: they stay in the session list for you to review later.", + settingAutoShuffle: 'Automatic reshuffle', + settingAutoShuffleHint: "Periodic Fisher–Yates shuffle of the upcoming queue. Off: order only changes when you rearrange it.", + settingShuffleInterval: 'Reshuffle every', + settingShuffleIntervalHint: 'How often the upcoming queue gets reshuffled while the session runs.', + suggestBlockedMuted: 'The host muted you for this session — suggestions are off.', + settingShuffleIntervalValue_one: '{{count}} min', + settingShuffleIntervalValue_other: '{{count}} min', + settingShuffleNow: 'Shuffle now', + toastSuggested: '{{user}} suggested "{{title}}"', + toastSuggestedMany: '{{count}} new suggestions in the queue', + toastShuffled: 'Queue shuffled', + toastJoined: 'Joined session', + toastLoginFirst: 'Log in before joining a session', + toastSwitchServer: 'Switch to {{url}} first, then paste again', + toastNoAccountForServer: "You don't have access to {{url}}. Ask the host for an invite.", + toastSwitchFailed: "Couldn't switch to {{url}}", + accountPickerTitle: 'Which account?', + accountPickerSub: 'You have more than one account for {{url}}. Pick the one to join the session as.', + toastJoinFail: "Couldn't join session", + joinErrNotFound: 'Session not found', + joinErrEnded: 'Session has ended', + joinErrFull: 'Session is full', + joinErrKicked: "You can't rejoin this session", + joinErrNoUser: 'No active server', + joinErrServerError: "Couldn't join", + ctxAddToSession: 'Add to Orbit session', + ctxSuggestedToast: 'Suggested to the session', + ctxSuggestFailed: "Couldn't suggest — not joined", + ctxAddToSessionHost: 'Add to Orbit session', + ctxAddedHostToast: 'Added to the session', + ctxAddHostFailed: "Couldn't add to session", + bulkConfirmTitle: 'Add all of this to the Orbit queue?', + bulkConfirmBody: "This would drop {{count}} tracks into the shared queue in one go. That's a lot for your guests to notice. Go ahead?", + bulkConfirmYes: 'Add them all', + bulkConfirmNo: 'Cancel', + guestLive: 'Live', + guestPlaying: 'Playing now', + guestPaused: 'Paused', + guestLoading: 'Loading…', + guestSuggestions: 'Suggestions', + guestUpNext: 'Up next', + guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.', + guestPendingTitle: 'Waiting for host', + guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.', + approvalTitle: 'Pending approvals', + approvalFrom: 'Suggested by {{user}}', + approvalAccept: 'Accept', + approvalDecline: 'Decline', + guestUpNextMore: '+ {{count}} more in the host\'s queue', + guestSubmitter: 'by {{user}}', + queueAddedByHost: 'Added by host', + queueAddedByYou: 'Added by you', + queueAddedByUser: 'Added by {{user}}', + guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".', + guestFooter: "The host controls playback — you can't change the list.", + exitKickedTitle: 'You were banned from the session', + exitRemovedTitle: 'You were removed from the session', + exitEndedTitle: 'The host ended the session', + exitHostTimeoutTitle: 'Host went silent', + exitHostTimeoutBody: "{{host}} hasn't sent an update for a while, so \"{{name}}\" was closed on your end. You can re-join any time with the invite link.", + exitKickedBody: '{{host}} banned you from "{{name}}". You can\'t re-join.', + exitRemovedBody: '{{host}} removed you from "{{name}}". You can re-join any time via the invite link.', + exitEndedBody: '"{{name}}" has ended. Hope you had fun.', + exitOk: 'OK', + }, }; diff --git a/src/locales/es.ts b/src/locales/es.ts index 30f0b473..3da001dd 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -22,6 +22,7 @@ export const esTranslation = { cancelDownload: 'Cancelar descarga', offlineLibrary: 'Biblioteca Offline', genres: 'Géneros', + tracks: 'Canciones', playlists: 'Listas de Reproducción', mostPlayed: 'Más Reproducidos', radio: 'Radio por Internet', @@ -41,6 +42,7 @@ export const esTranslation = { mostPlayed: 'Más Reproducidos', recentlyPlayed: 'Reproducidos Recientemente', discover: 'Descubrir', + discoverSongs: 'Descubrir canciones', loadMore: 'Cargar Más', discoverMore: 'Descubrir Más', discoverArtists: 'Descubrir Artistas', @@ -179,6 +181,7 @@ export const esTranslation = { }, albumDetail: { back: 'Volver', + orbitDoubleClickHint: 'Doble clic para añadir esta canción a la cola Orbit', playAll: 'Reproducir Todo', shareAlbum: 'Compartir álbum', enqueue: 'Agregar a la Cola', @@ -379,6 +382,20 @@ export const esTranslation = { offlineFailed: 'Error al agregar {{name}} offline', addToPlaylist: 'Agregar a Lista de Reproducción', }, + tracks: { + title: 'Canciones', + subtitle: 'Explorar. Buscar. Descubrir.', + heroEyebrow: 'Canción del momento', + heroReroll: 'Elegir otra', + playSong: 'Reproducir', + enqueueSong: 'Añadir a la cola', + railRandom: 'Selección aleatoria', + browseTitle: 'Explorar todas las canciones', + browseUnsupported: 'Este servidor no lista toda la biblioteca de una vez. Usa la búsqueda de arriba para encontrar canciones concretas.', + searchPlaceholder: 'Busca una canción por título, artista o álbum…', + count_one: '{{count}} canción', + count_other: '{{count}} canciones', + }, artists: { title: 'Artistas', search: 'Buscar…', @@ -663,6 +680,8 @@ export const esTranslation = { hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).", showArtistImages: 'Mostrar Imágenes de Artistas', showArtistImagesDesc: 'Carga y muestra imágenes de artistas en el resumen de Artistas. Desactivado por defecto para reducir I/O de disco del servidor y carga de red en bibliotecas grandes.', + showOrbitTrigger: 'Mostrar "Orbit" en la cabecera', + showOrbitTriggerDesc: 'El botón en la cabecera para iniciar o unirse a una sesión de escucha compartida. Ocúltalo si no usas Orbit — puedes volver a activarlo aquí.', showTrayIcon: 'Mostrar Icono en Bandeja', showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.', minimizeToTray: 'Minimizar a Bandeja', @@ -1476,4 +1495,183 @@ export const esTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Álbumes aleatorios', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Iniciar o unirse a una sesión de escucha compartida', + launchCreate: 'Crear una sesión', + launchJoin: 'Unirse a una sesión', + launchHelp: '¿Cómo funciona esto?', + launchHelpSoon: 'Próximamente', + joinModalTitle: 'Unirse a una sesión Orbit', + joinModalSub: 'Pega el enlace de invitación que te envió tu anfitrión.', + joinModalLinkLabel: 'Enlace de invitación', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Funciona con cualquier enlace Orbit válido. Debe apuntar al servidor al que estás conectado actualmente.', + joinModalPasteTooltip: 'Pegar desde el portapapeles', + joinModalSubmit: 'Unirse', + joinModalBusy: 'Uniéndose…', + joinErrEmpty: 'Pega un enlace de invitación.', + joinErrInvalid: 'Eso no parece un enlace de invitación Orbit.', + closeAria: 'Cerrar', + heroTitlePrefix: 'Escucha junto con', + heroTitleBrand: 'Orbit', + heroSub: 'Inicia una sesión — otros usuarios en {{server}} escucharán contigo y podrán sugerir canciones.', + fallbackServer: 'este servidor', + tipLan: 'Actualmente estás conectado mediante una dirección de red local. Los invitados fuera de tu red no pueden unirse — cambia a una dirección de servidor pública en los ajustes antes de empezar.', + tipRemote: 'Para que los invitados fuera de tu red doméstica puedan unirse, la dirección de tu servidor debe ser accesible públicamente. Si tu servidor es solo interno, cambia antes de empezar.', + labelName: 'Nombre de la sesión', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Nueva sugerencia', + reshuffleAria: 'Sugerir un nuevo nombre', + helperName: 'Cómo aparecerá la sesión a tus invitados — déjalo o escribe el tuyo.', + labelMax: 'Invitados máx.', + helperMax: 'Tú no cuentas — esto solo limita a los invitados entrantes.', + labelLink: 'Enlace de invitación', + tooltipCopied: 'Copiado', + tooltipCopy: 'Copiar', + ariaCopyLink: 'Copiar enlace de invitación', + helperLink: 'Envía este enlace a tus invitados. Pueden pegarlo en cualquier parte de Psysonic con Ctrl+V.', + labelClearQueue: 'Vaciar mi cola primero', + helperClearQueue: 'Empieza la sesión con una cola vacía. Desactivado: las canciones ya en cola se mantienen y se comparten con los invitados.', + btnCancel: 'Cancelar', + btnStarting: 'Iniciando…', + btnStart: 'Iniciar Orbit', + btnCopyAndStart: 'Copiar enlace e iniciar', + errNameRequired: 'Por favor, dale un nombre a tu sesión.', + errStartFailed: 'No se pudo iniciar.', + hostLabel: 'Anfitrión: {{name}}', + participantsTooltip: 'Participantes', + settingsTooltip: 'Ajustes de sesión', + shareTooltip: 'Compartir enlace de invitación', + shuffleLabel: 'Próximo mezclado en', + catchUpLabel: 'alcanzar', + catchUpTooltip: 'Saltar a la posición actual del anfitrión', + hostAway: 'Anfitrión desconectado', + hostOnline: 'Anfitrión en línea', + endTooltip: 'Terminar sesión', + leaveTooltip: 'Salir de la sesión', + helpTooltip: 'Cómo funciona Orbit', + helpTitle: 'Cómo funciona Orbit', + helpIntro: 'Orbit convierte Psysonic en una sala de escucha compartida. Un anfitrión elige la música; los invitados escuchan en sincronía y pueden sugerir canciones.', + helpHostOnly: '(anfitrión)', + helpSec1Title: '¿Qué es Orbit?', + helpSec1Body: 'Un modo de "escuchar juntos" — el anfitrión maneja la reproducción, los invitados se conectan. Todo pasa por playlists en tu propio servidor Navidrome; sin infraestructura externa.', + helpSec2Title: 'Anfitrión e invitados', + helpSec2Body: 'El anfitrión controla la reproducción (play / pause / skip / seek) y decide cuándo termina la sesión. Los invitados siguen la reproducción, pueden sugerir canciones y salir o volver a unirse en cualquier momento. Solo el anfitrión puede expulsar o banear permanentemente a un participante.', + helpSec2WarnHead: 'Importante: cuentas separadas.', + helpSec2WarnBody: 'Cada participante necesita su propia cuenta Navidrome. Si anfitrión e invitado inician sesión con el mismo usuario, sus envíos chocan y el anfitrión nunca ve las sugerencias del invitado.', + helpSec3Title: 'Iniciar e invitar', + helpSec3Body: 'Haz clic en el botón "Orbit" → Crear una sesión → el modal muestra un enlace de invitación listo para copiar y te permite opcionalmente vaciar tu cola actual. Comparte el enlace como prefieras. Mientras una sesión está activa, el botón de compartir en la barra superior copia el enlace en cualquier momento.', + helpSec4Title: 'Unirse', + helpSec4Body: 'Pega el enlace de invitación en cualquier parte de Psysonic con Ctrl+V / Cmd+V — el aviso para unirse aparece automáticamente. Alternativa: "Orbit" → Unirse a una sesión → pega en el campo. Si el enlace apunta a un servidor donde tienes una cuenta, Psysonic cambia automáticamente. Si tienes más de una cuenta en ese servidor, un pequeño selector pregunta cuál usar.', + helpSec5Title: 'Sugerir canciones', + helpSec5Body: 'En cualquier lista: doble clic en una fila, o clic derecho → "Añadir a la sesión Orbit". Un clic simple muestra una pista en lugar de tirar todo el álbum a la cola compartida — es intencional. Las acciones masivas explícitas como "Reproducir todo" o "Reproducir álbum" piden confirmación primero y luego añaden (nunca reemplazan).', + helpSec6Title: 'La cola compartida', + helpSec6Body: 'La cola muestra las próximas canciones del anfitrión — visible para todos. Las adiciones masivas siempre se añaden al final, para que las sugerencias de los invitados no se pierdan. El mezclado automático reordena la cola periódicamente (configurable: 1 / 5 / 10 / 15 / 30 min). Si tu reproducción se desvía del anfitrión, el botón "Alcanzar" en la barra te devuelve a la posición en vivo del anfitrión.', + helpSec7Title: 'Aprobaciones', + helpSec7Body: 'Por defecto, las sugerencias de los invitados necesitan aprobación manual — aparecen en una barra de "Aprobaciones" en la parte superior del panel con botones aceptar / rechazar. La aprobación automática puede activarse en los ajustes de sesión para que todo entre directamente.', + helpSec8Title: 'Participantes y ajustes', + helpSec8Body: 'Abre el icono de ajustes en la barra de sesión para la frecuencia de mezclado, la aprobación automática y el atajo "Mezclar ahora". Haz clic en el número de participantes para ver quién está conectado — expulsar (puede volver a unirse por el enlace) o banear (bloqueado para la sesión). Los invitados también ven la lista de participantes, pero solo de lectura.', + helpSec9Title: 'Terminar la sesión', + helpSec9Body: 'X del anfitrión → diálogo de confirmación → la sesión se cierra para todos y las playlists del servidor se limpian automáticamente. X del invitado → el invitado sale, la sesión sigue. Si el anfitrión queda en silencio 5 minutos, el invitado sale automáticamente con un aviso; las reconexiones cortas en esa ventana son invisibles.', + participantsInviteLabel: 'Enlace de invitación', + participantsCountLabel: '{{count}} en sesión', + participantsHost: 'anfitrión', + participantsEmpty: 'Aún no hay invitados', + participantsKickTooltip: 'Quitar de la sesión', + participantsKickAria: 'Quitar a {{user}}', + participantsRemoveTooltip: 'Quitar de la sesión', + participantsRemoveAria: 'Quitar a {{user}} de esta sesión', + participantsBanTooltip: 'Banear permanentemente', + participantsBanAria: 'Banear permanentemente a {{user}}', + participantsMuteTooltip: 'Silenciar sugerencias', + participantsUnmuteTooltip: 'Permitir sugerencias', + participantsMuteAria: 'Silenciar las sugerencias de {{user}}', + participantsUnmuteAria: 'Volver a permitir las sugerencias de {{user}}', + confirmRemoveTitle: '¿Quitar de la sesión?', + confirmRemoveBody: '{{user}} será expulsado de la sesión, pero puede volver a unirse por tu enlace de invitación.', + confirmRemoveConfirm: 'Quitar', + confirmBanTitle: '¿Banear permanentemente?', + confirmBanBody: '{{user}} será expulsado y bloqueado para volver a unirse a esta sesión. No se puede deshacer durante la sesión.', + confirmBanConfirm: 'Banear', + confirmCancel: 'Cancelar', + confirmJoinTitle: '¿Unirse a la sesión Orbit?', + confirmJoinBody: '{{host}} te ha invitado a "{{name}}". ¿Unirse a la sesión?', + confirmJoinConfirm: 'Unirse', + confirmLeaveTitle: '¿Salir de la sesión?', + confirmLeaveBody: '¿Salir de "{{name}}"? Puedes volver a unirte en cualquier momento por el enlace de invitación de {{host}}.', + confirmLeaveConfirm: 'Salir', + confirmEndTitle: '¿Terminar la sesión para todos?', + confirmEndBody: '"{{name}}" se cerrará para todos los participantes. Las playlists de la sesión se limpian automáticamente.', + confirmEndConfirm: 'Terminar sesión', + invalidLinkTitle: 'El enlace de invitación ya no es válido', + invalidLinkBody: 'Esta sesión Orbit ya no existe o ya ha terminado. Pide al anfitrión un nuevo enlace de invitación.', + settingsTitle: 'Ajustes de sesión', + settingAutoApprove: 'Aprobar sugerencias automáticamente', + settingAutoApproveHint: 'Las sugerencias de los invitados entran instantáneamente en tu cola. Desactivado: se quedan en la lista de sesión para revisión posterior.', + settingAutoShuffle: 'Mezclado automático', + settingAutoShuffleHint: 'Mezclado Fisher-Yates periódico de la cola próxima. Desactivado: el orden solo cambia cuando lo reorganizas.', + settingShuffleInterval: 'Remezclar cada', + settingShuffleIntervalHint: 'Con qué frecuencia se remezcla la cola próxima durante la sesión.', + suggestBlockedMuted: 'El anfitrión te silenció en esta sesión — sugerencias desactivadas.', + settingShuffleIntervalValue_one: '{{count}} min', + settingShuffleIntervalValue_other: '{{count}} min', + settingShuffleNow: 'Mezclar ahora', + toastSuggested: '{{user}} sugirió "{{title}}"', + toastSuggestedMany: '{{count}} nuevas sugerencias en la cola', + toastShuffled: 'Cola mezclada', + toastJoined: 'Te has unido a la sesión', + toastLoginFirst: 'Inicia sesión antes de unirte a una sesión', + toastSwitchServer: 'Cambia primero a {{url}}, luego pega de nuevo', + toastNoAccountForServer: 'No tienes acceso a {{url}}. Pide al anfitrión una invitación.', + toastSwitchFailed: 'No se pudo cambiar a {{url}}', + accountPickerTitle: '¿Qué cuenta?', + accountPickerSub: 'Tienes más de una cuenta para {{url}}. Elige con cuál unirte a la sesión.', + toastJoinFail: 'No se pudo unir a la sesión', + joinErrNotFound: 'Sesión no encontrada', + joinErrEnded: 'La sesión ha terminado', + joinErrFull: 'La sesión está llena', + joinErrKicked: 'No puedes volver a unirte a esta sesión', + joinErrNoUser: 'Sin servidor activo', + joinErrServerError: 'No se pudo unir', + ctxAddToSession: 'Añadir a la sesión Orbit', + ctxSuggestedToast: 'Sugerido a la sesión', + ctxSuggestFailed: 'No se pudo sugerir — no unido', + ctxAddToSessionHost: 'Añadir a la sesión Orbit', + ctxAddedHostToast: 'Añadido a la sesión', + ctxAddHostFailed: 'No se pudo añadir a la sesión', + bulkConfirmTitle: '¿Añadir todo esto a la cola Orbit?', + bulkConfirmBody: 'Esto lanzaría {{count}} canciones a la cola compartida de una vez. Es mucho para tus invitados. ¿Continuar?', + bulkConfirmYes: 'Añadir todas', + bulkConfirmNo: 'Cancelar', + guestLive: 'En vivo', + guestPlaying: 'Reproduciendo ahora', + guestPaused: 'En pausa', + guestLoading: 'Cargando…', + guestSuggestions: 'Sugerencias', + guestUpNext: 'A continuación', + guestUpNextEmpty: 'Nada en cola. Abre el menú contextual de una canción y elige "Añadir a la sesión Orbit" para sugerir una.', + guestPendingTitle: 'Esperando al anfitrión', + guestPendingHint: 'Sugerido — aparecerá en la cola cuando el anfitrión lo acepte.', + approvalTitle: 'Pendiente de aprobación', + approvalFrom: 'Sugerido por {{user}}', + approvalAccept: 'Aceptar', + approvalDecline: 'Rechazar', + guestUpNextMore: '+ {{count}} más en la cola del anfitrión', + guestSubmitter: 'de {{user}}', + queueAddedByHost: 'Añadido por el anfitrión', + queueAddedByYou: 'Añadido por ti', + queueAddedByUser: 'Añadido por {{user}}', + guestEmpty: 'Aún no hay sugerencias. Abre el menú contextual de una canción y elige "Añadir a la sesión Orbit".', + guestFooter: 'El anfitrión controla la reproducción — no puedes cambiar la lista.', + exitKickedTitle: 'Has sido baneado de la sesión', + exitRemovedTitle: 'Has sido quitado de la sesión', + exitEndedTitle: 'El anfitrión ha terminado la sesión', + exitHostTimeoutTitle: 'Anfitrión en silencio', + exitHostTimeoutBody: '{{host}} no ha enviado una actualización durante un rato, así que "{{name}}" se cerró en tu lado. Puedes volver a unirte en cualquier momento con el enlace de invitación.', + exitKickedBody: '{{host}} te ha baneado de "{{name}}". No puedes volver a unirte.', + exitRemovedBody: '{{host}} te ha quitado de "{{name}}". Puedes volver a unirte en cualquier momento por el enlace.', + exitEndedBody: '"{{name}}" ha terminado. Espero que te hayas divertido.', + exitOk: 'OK', + }, }; diff --git a/src/locales/fr.ts b/src/locales/fr.ts index f672ec2f..77f4624d 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -21,6 +21,7 @@ export const frTranslation = { cancelDownload: 'Annuler le téléchargement', offlineLibrary: 'Bibliothèque hors ligne', genres: 'Genres', + tracks: 'Titres', playlists: 'Playlists', mostPlayed: 'Les plus joués', radio: 'Radio Internet', @@ -40,6 +41,7 @@ export const frTranslation = { mostPlayed: 'Les plus écoutés', recentlyPlayed: 'Récemment écoutés', discover: 'Découvrir', + discoverSongs: 'Découvrir des titres', loadMore: 'Charger plus', discoverMore: 'Découvrir plus', discoverArtists: 'Découvrir des artistes', @@ -178,6 +180,7 @@ export const frTranslation = { }, albumDetail: { back: 'Retour', + orbitDoubleClickHint: 'Double-clic pour ajouter ce morceau à la file Orbit', playAll: 'Tout lire', shareAlbum: 'Partager l\'album', enqueue: 'Mettre en file', @@ -377,6 +380,20 @@ export const frTranslation = { offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…', offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne', }, + tracks: { + title: 'Titres', + subtitle: 'Parcourir. Chercher. Découvrir.', + heroEyebrow: 'Titre du moment', + heroReroll: 'En choisir un autre', + playSong: 'Lire', + enqueueSong: 'Ajouter à la file', + railRandom: 'Sélection aléatoire', + browseTitle: 'Parcourir tous les titres', + browseUnsupported: 'Ce serveur ne liste pas toute la bibliothèque d\'un coup. Utilisez la recherche ci-dessus pour trouver des titres précis.', + searchPlaceholder: 'Chercher un titre par titre, artiste ou album…', + count_one: '{{count}} titre', + count_other: '{{count}} titres', + }, artists: { title: 'Artistes', search: 'Rechercher…', @@ -660,6 +677,8 @@ export const frTranslation = { hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).", showArtistImages: 'Afficher les images d\'artistes', showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.', + showOrbitTrigger: 'Afficher « Orbit » dans l\'en-tête', + showOrbitTriggerDesc: 'Le bouton d\'en-tête pour démarrer ou rejoindre une session d\'écoute partagée. Masque-le si tu n\'utilises pas Orbit — tu peux le réactiver ici.', showTrayIcon: 'Afficher l\'icône dans la barre système', showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.', minimizeToTray: 'Réduire dans la barre système', @@ -1471,4 +1490,183 @@ export const frTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Albums aléatoires', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Démarrer ou rejoindre une session d\'écoute partagée', + launchCreate: 'Créer une session', + launchJoin: 'Rejoindre une session', + launchHelp: 'Comment ça marche ?', + launchHelpSoon: 'Bientôt disponible', + joinModalTitle: 'Rejoindre une session Orbit', + joinModalSub: 'Colle le lien d\'invitation que ton hôte t\'a envoyé.', + joinModalLinkLabel: 'Lien d\'invitation', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Fonctionne avec tout lien Orbit valide. Doit pointer vers le serveur auquel tu es actuellement connecté.', + joinModalPasteTooltip: 'Coller depuis le presse-papiers', + joinModalSubmit: 'Rejoindre', + joinModalBusy: 'Connexion…', + joinErrEmpty: 'Veuillez coller un lien d\'invitation.', + joinErrInvalid: 'Cela ne ressemble pas à un lien d\'invitation Orbit.', + closeAria: 'Fermer', + heroTitlePrefix: 'Écoute ensemble avec', + heroTitleBrand: 'Orbit', + heroSub: 'Démarre une session — les autres utilisateurs de {{server}} écouteront en même temps et pourront suggérer des morceaux.', + fallbackServer: 'ce serveur', + tipLan: 'Tu es actuellement connecté via une adresse de réseau local. Les invités hors de ton réseau ne peuvent pas rejoindre — bascule vers une adresse publique dans les paramètres avant de démarrer.', + tipRemote: 'Pour que des invités hors de ton réseau domestique puissent rejoindre, l\'adresse de ton serveur doit être publiquement accessible. Si ton serveur est interne, bascule avant de démarrer.', + labelName: 'Nom de la session', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Nouvelle suggestion', + reshuffleAria: 'Suggérer un nouveau nom', + helperName: 'Comment la session apparaîtra à tes invités — garde-le ou saisis le tien.', + labelMax: 'Invités max.', + helperMax: 'Tu ne comptes pas — ceci limite uniquement les invités entrants.', + labelLink: 'Lien d\'invitation', + tooltipCopied: 'Copié', + tooltipCopy: 'Copier', + ariaCopyLink: 'Copier le lien d\'invitation', + helperLink: 'Envoie ce lien à tes invités. Ils peuvent le coller n\'importe où dans Psysonic avec Ctrl+V.', + labelClearQueue: 'Vider ma file d\'attente d\'abord', + helperClearQueue: 'Démarre la session avec une file d\'attente vide. Désactivé : les titres déjà en file restent et sont partagés avec les invités.', + btnCancel: 'Annuler', + btnStarting: 'Démarrage…', + btnStart: 'Démarrer Orbit', + btnCopyAndStart: 'Copier le lien & démarrer', + errNameRequired: 'Veuillez donner un nom à ta session.', + errStartFailed: 'Impossible de démarrer.', + hostLabel: 'Hôte : {{name}}', + participantsTooltip: 'Participants', + settingsTooltip: 'Paramètres de session', + shareTooltip: 'Partager le lien d\'invitation', + shuffleLabel: 'Prochain mélange dans', + catchUpLabel: 'rattraper', + catchUpTooltip: 'Sauter à la position actuelle de l\'hôte', + hostAway: 'Hôte hors ligne', + hostOnline: 'Hôte en ligne', + endTooltip: 'Terminer la session', + leaveTooltip: 'Quitter la session', + helpTooltip: 'Fonctionnement d\'Orbit', + helpTitle: 'Fonctionnement d\'Orbit', + helpIntro: 'Orbit transforme Psysonic en salle d\'écoute partagée. Un hôte choisit la musique ; les invités écoutent en synchro et peuvent suggérer des morceaux.', + helpHostOnly: '(hôte)', + helpSec1Title: 'Qu\'est-ce qu\'Orbit ?', + helpSec1Body: 'Un mode « écouter ensemble » — l\'hôte dirige la lecture, les invités écoutent. Tout passe par des playlists sur ton propre serveur Navidrome ; aucune infrastructure externe.', + helpSec2Title: 'Hôte et invités', + helpSec2Body: 'L\'hôte contrôle la lecture (play / pause / skip / seek) et décide quand la session se termine. Les invités suivent la lecture, peuvent suggérer des morceaux et quitter ou rejoindre à tout moment. Seul l\'hôte peut exclure ou bannir un participant.', + helpSec2WarnHead: 'Important : comptes séparés.', + helpSec2WarnBody: 'Chaque participant a besoin de son propre compte Navidrome. Si l\'hôte et l\'invité utilisent le même utilisateur, leurs soumissions entrent en conflit et l\'hôte ne voit jamais les suggestions de l\'invité.', + helpSec3Title: 'Démarrer et inviter', + helpSec3Body: 'Clique sur le bouton « Orbit » → Créer une session → le modal affiche un lien d\'invitation prêt à copier et permet optionnellement de vider ta file actuelle. Partage le lien comme tu veux. Pendant qu\'une session est active, le bouton de partage dans la barre en haut copie le lien à tout moment.', + helpSec4Title: 'Rejoindre', + helpSec4Body: 'Colle le lien d\'invitation n\'importe où dans Psysonic avec Ctrl+V / Cmd+V — l\'invite apparaît automatiquement. Alternative : « Orbit » → Rejoindre une session → colle dans le champ. Si le lien pointe vers un serveur où tu as un compte, Psysonic bascule automatiquement. Si tu as plusieurs comptes sur ce serveur, un sélecteur demande lequel utiliser.', + helpSec5Title: 'Suggérer des morceaux', + helpSec5Body: 'Dans n\'importe quelle liste : double-clic sur une ligne, ou clic droit → « Ajouter à la session Orbit ». Un simple clic affiche un indice au lieu de jeter tout l\'album dans la file partagée — c\'est intentionnel. Les actions groupées explicites comme « Tout lire » ou « Lire l\'album » demandent d\'abord confirmation puis ajoutent (ne remplacent jamais).', + helpSec6Title: 'La file d\'attente partagée', + helpSec6Body: 'La file affiche les morceaux à venir de l\'hôte — visible par tous. Les ajouts groupés sont toujours ajoutés à la fin, pour ne pas perdre les suggestions des invités. Le mélange automatique réorganise la file périodiquement (configurable : 1 / 5 / 10 / 15 / 30 min). Si ta lecture dévie, le bouton « Rattraper » dans la barre te ramène à la position en direct de l\'hôte.', + helpSec7Title: 'Approbations', + helpSec7Body: 'Par défaut, les suggestions des invités nécessitent une approbation manuelle — elles apparaissent dans une bande « Approbations » en haut du panneau avec des boutons accepter / refuser. L\'approbation automatique peut être activée dans les paramètres.', + helpSec8Title: 'Participants et paramètres', + helpSec8Body: 'Ouvre l\'icône des paramètres dans la barre pour la cadence de mélange, l\'approbation automatique et le raccourci « Mélanger maintenant ». Clique sur le nombre de participants pour voir qui est connecté — exclure (peut rejoindre via le lien) ou bannir (exclu pour la session). Les invités voient la liste en lecture seule.', + helpSec9Title: 'Terminer la session', + helpSec9Body: 'X de l\'hôte → dialogue de confirmation → la session se ferme pour tout le monde, les playlists serveur sont nettoyées automatiquement. X de l\'invité → l\'invité part, la session continue. Si l\'hôte reste silencieux 5 minutes, l\'invité part automatiquement avec un avis ; les reconnexions courtes dans ce laps sont invisibles.', + participantsInviteLabel: 'Lien d\'invitation', + participantsCountLabel: '{{count}} en session', + participantsHost: 'hôte', + participantsEmpty: 'Pas encore d\'invités', + participantsKickTooltip: 'Retirer de la session', + participantsKickAria: 'Retirer {{user}}', + participantsRemoveTooltip: 'Retirer de la session', + participantsRemoveAria: 'Retirer {{user}} de cette session', + participantsBanTooltip: 'Bannir définitivement', + participantsBanAria: 'Bannir définitivement {{user}}', + participantsMuteTooltip: 'Bloquer les suggestions', + participantsUnmuteTooltip: 'Autoriser les suggestions', + participantsMuteAria: 'Bloquer les suggestions de {{user}}', + participantsUnmuteAria: 'Autoriser à nouveau les suggestions de {{user}}', + confirmRemoveTitle: 'Retirer de la session ?', + confirmRemoveBody: '{{user}} sera retiré de la session mais peut rejoindre via ton lien d\'invitation.', + confirmRemoveConfirm: 'Retirer', + confirmBanTitle: 'Bannir définitivement ?', + confirmBanBody: '{{user}} sera retiré et empêché de rejoindre cette session. Irréversible pour la durée de la session.', + confirmBanConfirm: 'Bannir', + confirmCancel: 'Annuler', + confirmJoinTitle: 'Rejoindre la session Orbit ?', + confirmJoinBody: '{{host}} t\'a invité à « {{name}} ». Rejoindre la session ?', + confirmJoinConfirm: 'Rejoindre', + confirmLeaveTitle: 'Quitter la session ?', + confirmLeaveBody: 'Quitter « {{name}} » ? Tu peux rejoindre à tout moment via le lien d\'invitation de {{host}}.', + confirmLeaveConfirm: 'Quitter', + confirmEndTitle: 'Terminer la session pour tout le monde ?', + confirmEndBody: '« {{name}} » se fermera pour tous les participants. Les playlists de session sont nettoyées automatiquement.', + confirmEndConfirm: 'Terminer la session', + invalidLinkTitle: 'Le lien d\'invitation n\'est plus valide', + invalidLinkBody: 'Cette session Orbit n\'existe plus ou est déjà terminée. Demande à l\'hôte un nouveau lien.', + settingsTitle: 'Paramètres de session', + settingAutoApprove: 'Approuver automatiquement les suggestions', + settingAutoApproveHint: 'Les suggestions arrivent instantanément dans ta file. Désactivé : elles restent dans la liste de session pour révision.', + settingAutoShuffle: 'Mélange automatique', + settingAutoShuffleHint: 'Mélange Fisher-Yates périodique de la file à venir. Désactivé : l\'ordre ne change que lorsque tu le réorganises.', + settingShuffleInterval: 'Remélanger toutes les', + settingShuffleIntervalHint: 'Fréquence de remélange de la file pendant la session.', + suggestBlockedMuted: 'L\'hôte t\'a coupé le micro pour cette session — suggestions désactivées.', + settingShuffleIntervalValue_one: '{{count}} min', + settingShuffleIntervalValue_other: '{{count}} min', + settingShuffleNow: 'Mélanger maintenant', + toastSuggested: '{{user}} a suggéré « {{title}} »', + toastSuggestedMany: '{{count}} nouvelles suggestions dans la file', + toastShuffled: 'File mélangée', + toastJoined: 'Session rejointe', + toastLoginFirst: 'Connecte-toi avant de rejoindre une session', + toastSwitchServer: 'Bascule d\'abord vers {{url}}, puis colle à nouveau', + toastNoAccountForServer: 'Tu n\'as pas accès à {{url}}. Demande à l\'hôte une invitation.', + toastSwitchFailed: 'Impossible de basculer vers {{url}}', + accountPickerTitle: 'Quel compte ?', + accountPickerSub: 'Tu as plusieurs comptes pour {{url}}. Choisis celui avec lequel rejoindre la session.', + toastJoinFail: 'Impossible de rejoindre la session', + joinErrNotFound: 'Session introuvable', + joinErrEnded: 'La session est terminée', + joinErrFull: 'La session est pleine', + joinErrKicked: 'Tu ne peux pas rejoindre cette session', + joinErrNoUser: 'Aucun serveur actif', + joinErrServerError: 'Impossible de rejoindre', + ctxAddToSession: 'Ajouter à la session Orbit', + ctxSuggestedToast: 'Suggéré à la session', + ctxSuggestFailed: 'Impossible de suggérer — pas rejoint', + ctxAddToSessionHost: 'Ajouter à la session Orbit', + ctxAddedHostToast: 'Ajouté à la session', + ctxAddHostFailed: 'Impossible d\'ajouter à la session', + bulkConfirmTitle: 'Tout ajouter à la file Orbit ?', + bulkConfirmBody: 'Cela ajouterait {{count}} morceaux en une fois. C\'est beaucoup pour tes invités. Continuer ?', + bulkConfirmYes: 'Tout ajouter', + bulkConfirmNo: 'Annuler', + guestLive: 'Direct', + guestPlaying: 'En cours de lecture', + guestPaused: 'En pause', + guestLoading: 'Chargement…', + guestSuggestions: 'Suggestions', + guestUpNext: 'À suivre', + guestUpNextEmpty: 'Rien en file. Ouvre le menu contextuel d\'un morceau et choisis « Ajouter à la session Orbit ».', + guestPendingTitle: 'En attente de l\'hôte', + guestPendingHint: 'Suggéré — apparaîtra dans la file quand l\'hôte l\'acceptera.', + approvalTitle: 'Approbations en attente', + approvalFrom: 'Suggéré par {{user}}', + approvalAccept: 'Accepter', + approvalDecline: 'Refuser', + guestUpNextMore: '+ {{count}} autres dans la file de l\'hôte', + guestSubmitter: 'par {{user}}', + queueAddedByHost: 'Ajouté par l\'hôte', + queueAddedByYou: 'Ajouté par toi', + queueAddedByUser: 'Ajouté par {{user}}', + guestEmpty: 'Aucune suggestion. Ouvre le menu contextuel d\'un morceau et choisis « Ajouter à la session Orbit ».', + guestFooter: 'L\'hôte contrôle la lecture — tu ne peux pas modifier la liste.', + exitKickedTitle: 'Tu as été banni de la session', + exitRemovedTitle: 'Tu as été retiré de la session', + exitEndedTitle: 'L\'hôte a terminé la session', + exitHostTimeoutTitle: 'L\'hôte ne répond plus', + exitHostTimeoutBody: '{{host}} n\'a pas envoyé de mise à jour depuis un moment, donc « {{name}} » a été fermée de ton côté. Tu peux rejoindre à tout moment avec le lien.', + exitKickedBody: '{{host}} t\'a banni de « {{name}} ». Tu ne peux pas rejoindre.', + exitRemovedBody: '{{host}} t\'a retiré de « {{name}} ». Tu peux rejoindre via le lien à tout moment.', + exitEndedBody: '« {{name}} » est terminée. J\'espère que tu t\'es bien amusé.', + exitOk: 'OK', + }, }; diff --git a/src/locales/nb.ts b/src/locales/nb.ts index acea7446..d8434943 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -21,6 +21,7 @@ export const nbTranslation = { cancelDownload: 'Avbryt nedlasting', offlineLibrary: 'Frakoblet bibliotek', genres: 'Sjangere', + tracks: 'Spor', playlists: 'Spillelister', mostPlayed: 'Mest spilt', radio: 'Internettradio', @@ -40,6 +41,7 @@ export const nbTranslation = { mostPlayed: 'Mest spilt', recentlyPlayed: 'Nylig spilt', discover: 'Oppdag', + discoverSongs: 'Oppdag spor', loadMore: 'Last inn flere', discoverMore: 'Oppdag flere', discoverArtists: 'Oppdag artister', @@ -178,6 +180,7 @@ export const nbTranslation = { }, albumDetail: { back: 'Tilbake', + orbitDoubleClickHint: 'Dobbeltklikk for å legge dette sporet til Orbit-køen', playAll: 'Spill alt', shareAlbum: 'Del album', enqueue: 'Legg i kø', @@ -377,6 +380,20 @@ export const nbTranslation = { offlineQueuing: 'Legger {{count}} album i kø for offline…', offlineFailed: 'Kunne ikke legge til {{name}} offline', }, + tracks: { + title: 'Spor', + subtitle: 'Bla. Søk. Oppdag.', + heroEyebrow: 'Sporet akkurat nå', + heroReroll: 'Velg et annet', + playSong: 'Spill av', + enqueueSong: 'Legg til i kø', + railRandom: 'Tilfeldig valg', + browseTitle: 'Bla gjennom alle spor', + browseUnsupported: 'Denne tjeneren lister ikke hele biblioteket på én gang. Bruk søket ovenfor for å finne bestemte spor.', + searchPlaceholder: 'Finn et spor etter tittel, artist eller album…', + count_one: '{{count}} spor', + count_other: '{{count}} spor', + }, artists: { title: 'Artister', search: 'Søk…', @@ -661,6 +678,8 @@ export const nbTranslation = { hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.", showArtistImages: 'Vis artistbilder', showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.', + showOrbitTrigger: 'Vis «Orbit» i toppen', + showOrbitTriggerDesc: 'Knappen i toppen for å starte eller bli med i en delt lytteøkt. Skjul den hvis du ikke bruker Orbit — du kan slå den på igjen her.', minimizeToTray: 'Minimer til oppgavelinjen', minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.', preloadMiniPlayer: 'Forhåndslast miniavspiller', @@ -1470,4 +1489,183 @@ export const nbTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Tilfeldige album', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Start eller bli med i en delt lytteøkt', + launchCreate: 'Opprett en økt', + launchJoin: 'Bli med i en økt', + launchHelp: 'Hvordan fungerer dette?', + launchHelpSoon: 'Kommer snart', + joinModalTitle: 'Bli med i en Orbit-økt', + joinModalSub: 'Lim inn invitasjonslenken verten sendte deg.', + joinModalLinkLabel: 'Invitasjonslenke', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Fungerer med enhver gyldig Orbit-lenke. Må peke til serveren du er innlogget på.', + joinModalPasteTooltip: 'Lim inn fra utklippstavlen', + joinModalSubmit: 'Bli med', + joinModalBusy: 'Blir med…', + joinErrEmpty: 'Lim inn en invitasjonslenke.', + joinErrInvalid: 'Dette ser ikke ut som en Orbit-invitasjonslenke.', + closeAria: 'Lukk', + heroTitlePrefix: 'Lytt sammen med', + heroTitleBrand: 'Orbit', + heroSub: 'Start en økt — andre brukere på {{server}} lytter med og kan foreslå spor.', + fallbackServer: 'denne serveren', + tipLan: 'Du er for øyeblikket koblet til via en hjemmenettverksadresse. Gjester utenfor nettverket kan ikke bli med — bytt til en offentlig serveradresse i innstillingene før du starter.', + tipRemote: 'For at gjester utenfor hjemmenettverket skal kunne bli med, må serveradressen være offentlig tilgjengelig. Hvis serveren din bare er intern, bytt før du starter.', + labelName: 'Øktnavn', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Nytt forslag', + reshuffleAria: 'Foreslå et nytt navn', + helperName: 'Slik vises økten for gjestene — behold den eller skriv din egen.', + labelMax: 'Maks gjester', + helperMax: 'Du telles ikke — dette begrenser bare innkommende gjester.', + labelLink: 'Invitasjonslenke', + tooltipCopied: 'Kopiert', + tooltipCopy: 'Kopier', + ariaCopyLink: 'Kopier invitasjonslenke', + helperLink: 'Send denne lenken til gjestene dine. De kan lime den inn hvor som helst i Psysonic med Ctrl+V.', + labelClearQueue: 'Tøm min kø først', + helperClearQueue: 'Start økten med en tom kø. Av: eksisterende spor beholdes og deles med gjester.', + btnCancel: 'Avbryt', + btnStarting: 'Starter…', + btnStart: 'Start Orbit', + btnCopyAndStart: 'Kopier lenke & start', + errNameRequired: 'Gi økten din et navn.', + errStartFailed: 'Kunne ikke starte.', + hostLabel: 'Vert: {{name}}', + participantsTooltip: 'Deltakere', + settingsTooltip: 'Øktinnstillinger', + shareTooltip: 'Del invitasjonslenke', + shuffleLabel: 'Kø stokkes på nytt om', + catchUpLabel: 'ta igjen', + catchUpTooltip: 'Hopp til vertens nåværende posisjon', + hostAway: 'Vert offline', + hostOnline: 'Vert online', + endTooltip: 'Avslutt økt', + leaveTooltip: 'Forlat økt', + helpTooltip: 'Hvordan Orbit fungerer', + helpTitle: 'Hvordan Orbit fungerer', + helpIntro: 'Orbit gjør Psysonic til et delt lytterom. En vert velger musikken; gjester lytter synkronisert og kan foreslå spor.', + helpHostOnly: '(vert)', + helpSec1Title: 'Hva er Orbit?', + helpSec1Body: 'En "lytte sammen"-modus — verten driver avspillingen, gjester blir med. Alt går via spillelister på din egen Navidrome-server; ingen ekstern infrastruktur.', + helpSec2Title: 'Vert og gjester', + helpSec2Body: 'Verten kontrollerer avspillingen (spill / pause / hopp / søk) og bestemmer når økten avsluttes. Gjester følger vertens avspilling, kan foreslå spor og forlate eller bli med igjen når som helst. Bare verten kan utvise eller permanent utestenge en deltaker.', + helpSec2WarnHead: 'Viktig: separate kontoer.', + helpSec2WarnBody: 'Hver deltaker trenger sin egen Navidrome-konto. Hvis vert og gjest logger inn med samme bruker kolliderer innsendingene, og verten ser aldri gjestens forslag.', + helpSec3Title: 'Starte og invitere', + helpSec3Body: 'Klikk på "Orbit"-knappen → Opprett en økt → startmodalen viser en klar-til-kopiering invitasjonslenke og lar deg eventuelt tømme gjeldende kø. Del lenken som du vil. Mens en økt kjører, kopierer delknappen i øktlinjen øverst lenken når som helst.', + helpSec4Title: 'Bli med', + helpSec4Body: 'Lim inn invitasjonslenken hvor som helst i Psysonic med Ctrl+V / Cmd+V — bli-med-spørsmålet dukker opp automatisk. Alternativt: "Orbit" → Bli med i en økt → lim inn i feltet. Hvis lenken peker til en server du har konto på, bytter Psysonic for deg. Ved flere kontoer på den serveren spør en liten velger hvilken som skal brukes.', + helpSec5Title: 'Foreslå spor', + helpSec5Body: 'I enhver sporliste: dobbeltklikk en rad, eller høyreklikk → "Legg til i Orbit-økt". Et enkelt klikk viser et hint i stedet for å dumpe hele albumet i den delte køen — det er bevisst. Eksplisitte bulk-handlinger som "Spill alt" eller "Spill album" ber først om bekreftelse og legger deretter til (erstatter aldri).', + helpSec6Title: 'Den delte køen', + helpSec6Body: 'Køen viser vertens kommende spor — synlig for alle. Innkommende bulk-tillegg legges alltid til bakerst, så gjesteforslag går ikke tapt. Auto-stokking omorganiserer køen periodisk (konfigurerbar: 1 / 5 / 10 / 15 / 30 min). Hvis avspillingen din avviker fra verten, bringer "Ta igjen"-knappen i øktlinjen deg tilbake til vertens liveposisjon.', + helpSec7Title: 'Godkjenninger', + helpSec7Body: 'Som standard trenger gjesteforslag manuell godkjenning — de vises i en "Godkjenninger"-stripe øverst i køpanelet med godta / avvis-knapper. Auto-godkjenning kan slås på i øktinnstillingene slik at alt lander direkte.', + helpSec8Title: 'Deltakere og innstillinger', + helpSec8Body: 'Åpne innstillingsikonet i øktlinjen for stokkingsfrekvens, auto-godkjenning og "Stokk nå"-snarveien. Klikk på deltakerantallet for å se hvem som er koblet til — utvis (kan bli med igjen via lenken) eller utesteng (utelåst for økten). Gjester ser også deltakerlisten, men bare-lesing.', + helpSec9Title: 'Avslutte økten', + helpSec9Body: 'Vert X → bekreftelsesdialog → økten lukkes for alle og serverspillelistene ryddes opp automatisk. Gjest X → gjesten forlater, økten fortsetter. Hvis verten er stille i 5 minutter, forlater gjesten automatisk med en melding; korte gjentilkoblinger innenfor tiden er usynlige.', + participantsInviteLabel: 'Invitasjonslenke', + participantsCountLabel: '{{count}} i økten', + participantsHost: 'vert', + participantsEmpty: 'Ingen gjester ennå', + participantsKickTooltip: 'Fjern fra økten', + participantsKickAria: 'Fjern {{user}}', + participantsRemoveTooltip: 'Fjern fra økten', + participantsRemoveAria: 'Fjern {{user}} fra denne økten', + participantsBanTooltip: 'Uteste permanent', + participantsBanAria: 'Uteste {{user}} permanent', + participantsMuteTooltip: 'Blokker forslag', + participantsUnmuteTooltip: 'Tillat forslag', + participantsMuteAria: 'Blokker forslag fra {{user}}', + participantsUnmuteAria: 'Tillat forslag fra {{user}} igjen', + confirmRemoveTitle: 'Fjerne fra økten?', + confirmRemoveBody: '{{user}} fjernes fra økten, men kan bli med igjen via invitasjonslenken din.', + confirmRemoveConfirm: 'Fjern', + confirmBanTitle: 'Uteste permanent?', + confirmBanBody: '{{user}} fjernes og hindres i å bli med i denne økten igjen. Kan ikke angres i løpet av økten.', + confirmBanConfirm: 'Uteste', + confirmCancel: 'Avbryt', + confirmJoinTitle: 'Bli med i Orbit-økt?', + confirmJoinBody: '{{host}} inviterte deg til "{{name}}". Bli med i økten?', + confirmJoinConfirm: 'Bli med', + confirmLeaveTitle: 'Forlate økten?', + confirmLeaveBody: 'Forlate "{{name}}"? Du kan bli med igjen når som helst via {{host}}s invitasjonslenke.', + confirmLeaveConfirm: 'Forlat', + confirmEndTitle: 'Avslutte økten for alle?', + confirmEndBody: '"{{name}}" lukkes for alle deltakere. Øktens spillelister ryddes opp automatisk.', + confirmEndConfirm: 'Avslutt økt', + invalidLinkTitle: 'Invitasjonslenken er ikke lenger gyldig', + invalidLinkBody: 'Denne Orbit-økten finnes ikke lenger eller er allerede avsluttet. Be verten om en ny invitasjonslenke.', + settingsTitle: 'Øktinnstillinger', + settingAutoApprove: 'Godkjenn forslag automatisk', + settingAutoApproveHint: 'Gjesteforslag lander umiddelbart i køen din. Av: de blir værende i øktlisten for senere gjennomgang.', + settingAutoShuffle: 'Automatisk stokking', + settingAutoShuffleHint: 'Periodisk Fisher-Yates-stokking av kommende kø. Av: rekkefølgen endres bare når du omorganiserer den.', + settingShuffleInterval: 'Stokk på nytt hver', + settingShuffleIntervalHint: 'Hvor ofte kommende kø stokkes på nytt mens økten kjører.', + suggestBlockedMuted: 'Verten har blokkert deg for denne økten — forslag er av.', + settingShuffleIntervalValue_one: '{{count}} min', + settingShuffleIntervalValue_other: '{{count}} min', + settingShuffleNow: 'Stokk nå', + toastSuggested: '{{user}} foreslo "{{title}}"', + toastSuggestedMany: '{{count}} nye forslag i køen', + toastShuffled: 'Kø stokket', + toastJoined: 'Ble med i økt', + toastLoginFirst: 'Logg inn før du blir med i en økt', + toastSwitchServer: 'Bytt til {{url}} først, og lim inn igjen', + toastNoAccountForServer: 'Du har ikke tilgang til {{url}}. Be verten om en invitasjon.', + toastSwitchFailed: 'Kunne ikke bytte til {{url}}', + accountPickerTitle: 'Hvilken konto?', + accountPickerSub: 'Du har mer enn én konto for {{url}}. Velg den du vil bli med i økten med.', + toastJoinFail: 'Kunne ikke bli med i økten', + joinErrNotFound: 'Økt ikke funnet', + joinErrEnded: 'Økten er avsluttet', + joinErrFull: 'Økten er full', + joinErrKicked: 'Du kan ikke bli med i denne økten igjen', + joinErrNoUser: 'Ingen aktiv server', + joinErrServerError: 'Kunne ikke bli med', + ctxAddToSession: 'Legg til i Orbit-økt', + ctxSuggestedToast: 'Foreslått til økten', + ctxSuggestFailed: 'Kunne ikke foreslå — ikke blitt med', + ctxAddToSessionHost: 'Legg til i Orbit-økt', + ctxAddedHostToast: 'Lagt til i økten', + ctxAddHostFailed: 'Kunne ikke legge til i økten', + bulkConfirmTitle: 'Legg alt til i Orbit-køen?', + bulkConfirmBody: 'Dette vil slippe {{count}} spor i den delte køen på én gang. Det er mye for gjestene dine. Fortsette?', + bulkConfirmYes: 'Legg til alle', + bulkConfirmNo: 'Avbryt', + guestLive: 'Live', + guestPlaying: 'Spiller nå', + guestPaused: 'Pauset', + guestLoading: 'Laster…', + guestSuggestions: 'Forslag', + guestUpNext: 'Neste', + guestUpNextEmpty: 'Ingenting i kø. Åpne et spors kontekstmeny og velg "Legg til i Orbit-økt" for å foreslå ett.', + guestPendingTitle: 'Venter på vert', + guestPendingHint: 'Foreslått — dukker opp i køen når verten tar det.', + approvalTitle: 'Venter på godkjenning', + approvalFrom: 'Foreslått av {{user}}', + approvalAccept: 'Godta', + approvalDecline: 'Avvis', + guestUpNextMore: '+ {{count}} flere i vertens kø', + guestSubmitter: 'av {{user}}', + queueAddedByHost: 'Lagt til av verten', + queueAddedByYou: 'Lagt til av deg', + queueAddedByUser: 'Lagt til av {{user}}', + guestEmpty: 'Ingen forslag ennå. Åpne et spors kontekstmeny og velg "Legg til i Orbit-økt".', + guestFooter: 'Verten kontrollerer avspillingen — du kan ikke endre listen.', + exitKickedTitle: 'Du ble utestengt fra økten', + exitRemovedTitle: 'Du ble fjernet fra økten', + exitEndedTitle: 'Verten avsluttet økten', + exitHostTimeoutTitle: 'Vert ikke tilgjengelig', + exitHostTimeoutBody: '{{host}} har ikke sendt en oppdatering på en stund, så "{{name}}" ble lukket på din side. Du kan bli med igjen når som helst med invitasjonslenken.', + exitKickedBody: '{{host}} utestengte deg fra "{{name}}". Du kan ikke bli med igjen.', + exitRemovedBody: '{{host}} fjernet deg fra "{{name}}". Du kan bli med igjen når som helst via invitasjonslenken.', + exitEndedBody: '"{{name}}" er avsluttet. Håper du hadde det gøy.', + exitOk: 'OK', + }, }; diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 8446652f..1cfb04ea 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -21,6 +21,7 @@ export const nlTranslation = { cancelDownload: 'Download annuleren', offlineLibrary: 'Offline bibliotheek', genres: 'Genres', + tracks: 'Nummers', playlists: 'Playlists', mostPlayed: 'Meest gespeeld', radio: 'Internetradio', @@ -40,6 +41,7 @@ export const nlTranslation = { mostPlayed: 'Meest gespeeld', recentlyPlayed: 'Recent afgespeeld', discover: 'Ontdekken', + discoverSongs: 'Nummers ontdekken', loadMore: 'Meer laden', discoverMore: 'Meer ontdekken', discoverArtists: 'Artiesten ontdekken', @@ -177,6 +179,7 @@ export const nlTranslation = { }, albumDetail: { back: 'Terug', + orbitDoubleClickHint: 'Dubbelklik om dit nummer aan de Orbit-wachtrij toe te voegen', playAll: 'Alles afspelen', shareAlbum: 'Album delen', enqueue: 'In wachtrij', @@ -376,6 +379,20 @@ export const nlTranslation = { offlineQueuing: '{{count}} album(s) in wachtrij voor offline…', offlineFailed: 'Toevoegen van {{name}} offline mislukt', }, + tracks: { + title: 'Nummers', + subtitle: 'Bladeren. Zoeken. Ontdekken.', + heroEyebrow: 'Nummer van het moment', + heroReroll: 'Een ander kiezen', + playSong: 'Afspelen', + enqueueSong: 'Aan wachtrij toevoegen', + railRandom: 'Willekeurige selectie', + browseTitle: 'Alle nummers doorbladeren', + browseUnsupported: 'Deze server toont niet de hele bibliotheek in één keer. Gebruik de zoekbalk hierboven om specifieke nummers te vinden.', + searchPlaceholder: 'Zoek op titel, artiest of album…', + count_one: '{{count}} nummer', + count_other: '{{count}} nummers', + }, artists: { title: 'Artiesten', search: 'Zoeken…', @@ -659,6 +676,8 @@ export const nlTranslation = { hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.", showArtistImages: 'Artiestafbeeldingen weergeven', showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.', + showOrbitTrigger: '"Orbit" in de kop tonen', + showOrbitTriggerDesc: 'De knop in de kop voor starten of deelnemen aan een gedeelde luistersessie. Verberg hem als je Orbit niet gebruikt — je kunt hem hier weer aanzetten.', showTrayIcon: 'Tray-pictogram weergeven', showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.', minimizeToTray: 'Minimaliseren naar systeemvak', @@ -1470,4 +1489,183 @@ export const nlTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Willekeurige albums', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Een gedeelde luistersessie starten of eraan deelnemen', + launchCreate: 'Sessie aanmaken', + launchJoin: 'Deelnemen aan sessie', + launchHelp: 'Hoe werkt dit?', + launchHelpSoon: 'Binnenkort beschikbaar', + joinModalTitle: 'Deelnemen aan een Orbit-sessie', + joinModalSub: 'Plak de uitnodigingslink die je host je gestuurd heeft.', + joinModalLinkLabel: 'Uitnodigingslink', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Werkt met elke geldige Orbit-link. Moet verwijzen naar de server waarop je nu bent aangemeld.', + joinModalPasteTooltip: 'Plakken vanaf klembord', + joinModalSubmit: 'Deelnemen', + joinModalBusy: 'Deelnemen…', + joinErrEmpty: 'Plak een uitnodigingslink.', + joinErrInvalid: 'Dat lijkt geen Orbit-uitnodigingslink.', + closeAria: 'Sluiten', + heroTitlePrefix: 'Luister samen met', + heroTitleBrand: 'Orbit', + heroSub: 'Start een sessie — andere gebruikers op {{server}} luisteren mee en kunnen nummers voorstellen.', + fallbackServer: 'deze server', + tipLan: 'Je bent momenteel verbonden via een thuisnetwerkadres. Gasten van buiten je netwerk kunnen niet deelnemen — schakel over naar een openbaar serveradres in de instellingen voordat je start.', + tipRemote: 'Zodat gasten van buiten je thuisnetwerk kunnen deelnemen, moet je serveradres publiek bereikbaar zijn. Als je server alleen intern is, schakel om voordat je start.', + labelName: 'Sessienaam', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Nieuwe suggestie', + reshuffleAria: 'Een nieuwe naam voorstellen', + helperName: 'Hoe de sessie voor je gasten verschijnt — laat staan of typ je eigen naam.', + labelMax: 'Max. gasten', + helperMax: 'Jij telt niet mee — dit beperkt alleen binnenkomende gasten.', + labelLink: 'Uitnodigingslink', + tooltipCopied: 'Gekopieerd', + tooltipCopy: 'Kopiëren', + ariaCopyLink: 'Uitnodigingslink kopiëren', + helperLink: 'Stuur deze link naar je gasten. Ze kunnen hem overal in Psysonic plakken met Ctrl+V.', + labelClearQueue: 'Eerst mijn wachtrij legen', + helperClearQueue: 'Begin de sessie met een lege wachtrij. Uit: bestaande nummers blijven en worden met gasten gedeeld.', + btnCancel: 'Annuleren', + btnStarting: 'Starten…', + btnStart: 'Orbit starten', + btnCopyAndStart: 'Link kopiëren & starten', + errNameRequired: 'Geef je sessie een naam.', + errStartFailed: 'Kon niet starten.', + hostLabel: 'Host: {{name}}', + participantsTooltip: 'Deelnemers', + settingsTooltip: 'Sessie-instellingen', + shareTooltip: 'Uitnodigingslink delen', + shuffleLabel: 'Volgende shuffle in', + catchUpLabel: 'inhalen', + catchUpTooltip: 'Spring naar de huidige positie van de host', + hostAway: 'Host offline', + hostOnline: 'Host online', + endTooltip: 'Sessie beëindigen', + leaveTooltip: 'Sessie verlaten', + helpTooltip: 'Hoe Orbit werkt', + helpTitle: 'Hoe Orbit werkt', + helpIntro: 'Orbit maakt van Psysonic een gedeelde luisterruimte. Een host kiest de muziek; gasten luisteren synchroon mee en kunnen nummers voorstellen.', + helpHostOnly: '(host)', + helpSec1Title: 'Wat is Orbit?', + helpSec1Body: 'Een "samen luisteren"-modus — de host stuurt de weergave, gasten sluiten aan. Alles loopt via playlists op je eigen Navidrome-server; geen externe infrastructuur.', + helpSec2Title: 'Host en gasten', + helpSec2Body: 'De host bedient de weergave (play / pause / skip / seek) en beslist wanneer de sessie eindigt. Gasten volgen de weergave, kunnen nummers voorstellen en elk moment verlaten of opnieuw deelnemen. Alleen de host kan deelnemers kicken of permanent bannen.', + helpSec2WarnHead: 'Belangrijk: aparte accounts.', + helpSec2WarnBody: 'Elke deelnemer heeft een eigen Navidrome-account nodig. Als host en gast met dezelfde gebruiker inloggen botsen hun inzendingen en de host ziet nooit de suggesties van de gast.', + helpSec3Title: 'Starten en uitnodigen', + helpSec3Body: 'Klik op de "Orbit"-knop → Sessie aanmaken → het startmodal toont een kopieerklare uitnodigingslink en laat je optioneel je huidige wachtrij legen. Deel de link hoe je wilt. Terwijl een sessie draait, kopieert de deelknop in de sessiebalk de link op elk moment opnieuw.', + helpSec4Title: 'Deelnemen', + helpSec4Body: 'Plak de uitnodigingslink ergens in Psysonic met Ctrl+V / Cmd+V — de deelprompt verschijnt automatisch. Alternatief: "Orbit" → Deelnemen aan sessie → plak in het veld. Als de link naar een server verwijst waar je een account hebt, schakelt Psysonic voor je. Bij meerdere accounts op die server vraagt een kleine kiezer welke te gebruiken.', + helpSec5Title: 'Nummers voorstellen', + helpSec5Body: 'In elke lijst: dubbelklik op een rij, of rechtermuisknop → "Toevoegen aan Orbit-sessie". Een enkele klik toont een hint in plaats van het hele album in de gedeelde wachtrij te gooien — dat is bewust. Expliciete bulkacties zoals "Alles afspelen" of "Album afspelen" vragen eerst om bevestiging en voegen dan toe (vervangen nooit).', + helpSec6Title: 'De gedeelde wachtrij', + helpSec6Body: 'De wachtrij toont de komende nummers van de host — voor iedereen zichtbaar. Inkomende bulktoevoegingen worden altijd achteraan geplaatst, zodat suggesties van gasten niet verloren gaan. Auto-shuffle hergroepeert de wachtrij periodiek (instelbaar: 1 / 5 / 10 / 15 / 30 min). Als je weergave afwijkt van de host, brengt de "Inhalen"-knop in de sessiebalk je terug naar de live positie van de host.', + helpSec7Title: 'Goedkeuringen', + helpSec7Body: 'Standaard hebben gast-suggesties handmatige goedkeuring nodig — ze verschijnen in een "Goedkeuringen"-strook bovenaan het wachtrijpaneel met accepteer- / afwijsknoppen. Auto-goedkeuring kan in de sessie-instellingen aan, zodat alles direct doorgaat.', + helpSec8Title: 'Deelnemers en instellingen', + helpSec8Body: 'Open het instellingen-icoon in de sessiebalk voor shuffle-cadans, auto-goedkeuring en de "Nu shufflen"-sneltoets. Klik op het deelnemersaantal om te zien wie verbonden is — kicken (kan via de link opnieuw deelnemen) of bannen (voor de sessie uitgesloten). Gasten zien de deelnemerslijst ook, maar alleen-lezen.', + helpSec9Title: 'De sessie beëindigen', + helpSec9Body: 'Host-X → bevestigingsdialoog → de sessie sluit voor iedereen en de server-playlists worden automatisch opgeruimd. Gast-X → de gast verlaat, de sessie gaat door. Als de host 5 minuten stil blijft, verlaat de gast automatisch met een melding; korte reconnects binnen die tijd zijn onzichtbaar.', + participantsInviteLabel: 'Uitnodigingslink', + participantsCountLabel: '{{count}} in sessie', + participantsHost: 'host', + participantsEmpty: 'Nog geen gasten', + participantsKickTooltip: 'Uit sessie verwijderen', + participantsKickAria: '{{user}} verwijderen', + participantsRemoveTooltip: 'Uit sessie verwijderen', + participantsRemoveAria: '{{user}} uit deze sessie verwijderen', + participantsBanTooltip: 'Permanent bannen', + participantsBanAria: '{{user}} permanent bannen', + participantsMuteTooltip: 'Voorstellen blokkeren', + participantsUnmuteTooltip: 'Voorstellen toestaan', + participantsMuteAria: 'Voorstellen van {{user}} blokkeren', + participantsUnmuteAria: 'Voorstellen van {{user}} weer toestaan', + confirmRemoveTitle: 'Uit sessie verwijderen?', + confirmRemoveBody: '{{user}} wordt uit de sessie verwijderd, maar kan opnieuw deelnemen via je uitnodigingslink.', + confirmRemoveConfirm: 'Verwijderen', + confirmBanTitle: 'Permanent bannen?', + confirmBanBody: '{{user}} wordt verwijderd en kan niet meer aan deze sessie deelnemen. Onomkeerbaar voor de duur van de sessie.', + confirmBanConfirm: 'Bannen', + confirmCancel: 'Annuleren', + confirmJoinTitle: 'Deelnemen aan Orbit-sessie?', + confirmJoinBody: '{{host}} heeft je uitgenodigd voor "{{name}}". Aan de sessie deelnemen?', + confirmJoinConfirm: 'Deelnemen', + confirmLeaveTitle: 'Sessie verlaten?', + confirmLeaveBody: 'Wil je "{{name}}" verlaten? Je kunt altijd opnieuw deelnemen via de uitnodigingslink van {{host}}.', + confirmLeaveConfirm: 'Verlaten', + confirmEndTitle: 'Sessie voor iedereen beëindigen?', + confirmEndBody: '"{{name}}" wordt voor alle deelnemers gesloten. De sessie-playlists worden automatisch opgeruimd.', + confirmEndConfirm: 'Sessie beëindigen', + invalidLinkTitle: 'Uitnodigingslink is niet meer geldig', + invalidLinkBody: 'Deze Orbit-sessie bestaat niet meer of is al beëindigd. Vraag de host om een nieuwe uitnodigingslink.', + settingsTitle: 'Sessie-instellingen', + settingAutoApprove: 'Suggesties automatisch goedkeuren', + settingAutoApproveHint: 'Gast-suggesties komen direct in je wachtrij. Uit: ze blijven in de sessielijst voor latere beoordeling.', + settingAutoShuffle: 'Automatisch shufflen', + settingAutoShuffleHint: 'Periodieke Fisher-Yates-shuffle van de komende wachtrij. Uit: de volgorde verandert alleen wanneer je zelf herschikt.', + settingShuffleInterval: 'Opnieuw shufflen elke', + settingShuffleIntervalHint: 'Hoe vaak de komende wachtrij tijdens de sessie opnieuw wordt geshuffled.', + suggestBlockedMuted: 'De host heeft je voor deze sessie geblokkeerd — voorstellen uitgeschakeld.', + settingShuffleIntervalValue_one: '{{count}} min', + settingShuffleIntervalValue_other: '{{count}} min', + settingShuffleNow: 'Nu shufflen', + toastSuggested: '{{user}} stelde "{{title}}" voor', + toastSuggestedMany: '{{count}} nieuwe suggesties in de wachtrij', + toastShuffled: 'Wachtrij geshuffled', + toastJoined: 'Deelgenomen aan sessie', + toastLoginFirst: 'Log eerst in voordat je aan een sessie deelneemt', + toastSwitchServer: 'Schakel eerst naar {{url}} en plak opnieuw', + toastNoAccountForServer: 'Je hebt geen toegang tot {{url}}. Vraag de host om een uitnodiging.', + toastSwitchFailed: 'Kon niet schakelen naar {{url}}', + accountPickerTitle: 'Welk account?', + accountPickerSub: 'Je hebt meer dan één account voor {{url}}. Kies waarmee je wilt deelnemen.', + toastJoinFail: 'Kon niet deelnemen aan sessie', + joinErrNotFound: 'Sessie niet gevonden', + joinErrEnded: 'De sessie is beëindigd', + joinErrFull: 'De sessie is vol', + joinErrKicked: 'Je kunt niet opnieuw deelnemen aan deze sessie', + joinErrNoUser: 'Geen actieve server', + joinErrServerError: 'Kon niet deelnemen', + ctxAddToSession: 'Aan Orbit-sessie toevoegen', + ctxSuggestedToast: 'Aan sessie voorgesteld', + ctxSuggestFailed: 'Kon niet voorstellen — niet deelgenomen', + ctxAddToSessionHost: 'Aan Orbit-sessie toevoegen', + ctxAddedHostToast: 'Aan sessie toegevoegd', + ctxAddHostFailed: 'Kon niet toevoegen aan sessie', + bulkConfirmTitle: 'Alles aan de Orbit-wachtrij toevoegen?', + bulkConfirmBody: 'Dit zou {{count}} nummers in één keer in de gedeelde wachtrij plaatsen. Dat is veel voor je gasten. Doorgaan?', + bulkConfirmYes: 'Alles toevoegen', + bulkConfirmNo: 'Annuleren', + guestLive: 'Live', + guestPlaying: 'Speelt nu', + guestPaused: 'Gepauzeerd', + guestLoading: 'Laden…', + guestSuggestions: 'Suggesties', + guestUpNext: 'Volgende', + guestUpNextEmpty: 'Niets in de wachtrij. Open het contextmenu van een nummer en kies "Aan Orbit-sessie toevoegen".', + guestPendingTitle: 'Wacht op host', + guestPendingHint: 'Voorgesteld — verschijnt in de wachtrij zodra de host het oppakt.', + approvalTitle: 'Wacht op goedkeuring', + approvalFrom: 'Voorgesteld door {{user}}', + approvalAccept: 'Accepteren', + approvalDecline: 'Weigeren', + guestUpNextMore: '+ {{count}} meer in de wachtrij van de host', + guestSubmitter: 'door {{user}}', + queueAddedByHost: 'Toegevoegd door host', + queueAddedByYou: 'Toegevoegd door jou', + queueAddedByUser: 'Toegevoegd door {{user}}', + guestEmpty: 'Nog geen suggesties. Open het contextmenu van een nummer en kies "Aan Orbit-sessie toevoegen".', + guestFooter: 'De host bedient de weergave — je kunt de lijst niet wijzigen.', + exitKickedTitle: 'Je bent uit de sessie gebannen', + exitRemovedTitle: 'Je bent uit de sessie verwijderd', + exitEndedTitle: 'De host heeft de sessie beëindigd', + exitHostTimeoutTitle: 'Host niet bereikbaar', + exitHostTimeoutBody: '{{host}} heeft al een tijd geen update meer verstuurd, dus "{{name}}" is aan jouw kant gesloten. Je kunt altijd opnieuw deelnemen via de link.', + exitKickedBody: '{{host}} heeft je uit "{{name}}" gebannen. Je kunt niet opnieuw deelnemen.', + exitRemovedBody: '{{host}} heeft je uit "{{name}}" verwijderd. Je kunt altijd opnieuw deelnemen via de link.', + exitEndedBody: '"{{name}}" is beëindigd. Hopelijk had je plezier.', + exitOk: 'OK', + }, }; diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 1450e035..22a1e60a 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -22,6 +22,7 @@ export const ruTranslation = { cancelDownload: 'Отменить загрузку', offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', + tracks: 'Треки', playlists: 'Плейлисты', smartPlaylists: 'Смарт-плейлисты', mostPlayed: 'Популярное', @@ -42,6 +43,7 @@ export const ruTranslation = { mostPlayed: 'Популярное', recentlyPlayed: 'Недавно проиграно', discover: 'Обзор', + discoverSongs: 'Открыть треки', loadMore: 'Ещё', discoverMore: 'Смотреть ещё', discoverArtists: 'Исполнители', @@ -188,6 +190,7 @@ export const ruTranslation = { }, albumDetail: { back: 'Назад', + orbitDoubleClickHint: 'Двойной клик, чтобы добавить этот трек в очередь Orbit', playAll: 'Воспроизвести всё', shareAlbum: 'Поделиться альбомом', enqueue: 'В очередь', @@ -401,6 +404,22 @@ export const ruTranslation = { offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…', offlineFailed: 'Не удалось добавить {{name}} офлайн', }, + tracks: { + title: 'Треки', + subtitle: 'Листать. Искать. Открывать.', + heroEyebrow: 'Трек момента', + heroReroll: 'Выбрать другой', + playSong: 'Воспроизвести', + enqueueSong: 'В очередь', + railRandom: 'Случайная подборка', + browseTitle: 'Просмотреть все треки', + browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.', + searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…', + count_one: '{{count}} трек', + count_few: '{{count}} трека', + count_many: '{{count}} треков', + count_other: '{{count}} треков', + }, artists: { title: 'Исполнители', search: 'Поиск…', @@ -693,6 +712,8 @@ export const ruTranslation = { showArtistImages: 'Фото исполнителей', showArtistImagesDesc: 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', + showOrbitTrigger: 'Показывать «Orbit» в шапке', + showOrbitTriggerDesc: 'Кнопка в шапке для запуска или присоединения к совместной сессии. Скрой, если не используешь Orbit — здесь же можно включить обратно.', showTrayIcon: 'Иконка в трее', showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', minimizeToTray: 'Сворачивать в трей', @@ -1550,4 +1571,185 @@ export const ruTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Случайные альбомы', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: 'Начать или присоединиться к совместной сессии прослушивания', + launchCreate: 'Создать сессию', + launchJoin: 'Присоединиться к сессии', + launchHelp: 'Как это работает?', + launchHelpSoon: 'Скоро будет', + joinModalTitle: 'Присоединиться к сессии Orbit', + joinModalSub: 'Вставь ссылку-приглашение от хоста.', + joinModalLinkLabel: 'Ссылка-приглашение', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: 'Работает с любой действительной ссылкой Orbit. Должна указывать на сервер, на котором ты сейчас авторизован.', + joinModalPasteTooltip: 'Вставить из буфера обмена', + joinModalSubmit: 'Присоединиться', + joinModalBusy: 'Подключение…', + joinErrEmpty: 'Вставь ссылку-приглашение.', + joinErrInvalid: 'Это не похоже на ссылку-приглашение Orbit.', + closeAria: 'Закрыть', + heroTitlePrefix: 'Слушай вместе с', + heroTitleBrand: 'Orbit', + heroSub: 'Начни сессию — другие пользователи на {{server}} будут слушать вместе и смогут предлагать треки.', + fallbackServer: 'этот сервер', + tipLan: 'Ты сейчас подключён через адрес домашней сети. Гости вне твоей сети не смогут присоединиться — переключись на публичный адрес сервера в настройках перед стартом.', + tipRemote: 'Чтобы гости вне домашней сети могли присоединиться, адрес сервера должен быть публично доступен. Если сервер только внутренний, переключись перед стартом.', + labelName: 'Название сессии', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Новое предложение', + reshuffleAria: 'Предложить новое название', + helperName: 'Как сессия будет отображаться у гостей — оставь или введи своё.', + labelMax: 'Макс. гостей', + helperMax: 'Ты не считаешься — это только ограничивает входящих гостей.', + labelLink: 'Ссылка-приглашение', + tooltipCopied: 'Скопировано', + tooltipCopy: 'Копировать', + ariaCopyLink: 'Копировать ссылку-приглашение', + helperLink: 'Отправь эту ссылку гостям. Они могут вставить её в Psysonic в любом месте с помощью Ctrl+V.', + labelClearQueue: 'Сначала очистить мою очередь', + helperClearQueue: 'Начать сессию с пустой очередью. Выкл.: уже поставленные треки остаются и делятся с гостями.', + btnCancel: 'Отмена', + btnStarting: 'Запуск…', + btnStart: 'Запустить Orbit', + btnCopyAndStart: 'Копировать ссылку и запустить', + errNameRequired: 'Пожалуйста, дай сессии название.', + errStartFailed: 'Не удалось запустить.', + hostLabel: 'Хост: {{name}}', + participantsTooltip: 'Участники', + settingsTooltip: 'Настройки сессии', + shareTooltip: 'Поделиться ссылкой-приглашением', + shuffleLabel: 'Следующее перемешивание через', + catchUpLabel: 'догнать', + catchUpTooltip: 'Перейти к текущей позиции хоста', + hostAway: 'Хост оффлайн', + hostOnline: 'Хост онлайн', + endTooltip: 'Завершить сессию', + leaveTooltip: 'Покинуть сессию', + helpTooltip: 'Как работает Orbit', + helpTitle: 'Как работает Orbit', + helpIntro: 'Orbit превращает Psysonic в общую комнату прослушивания. Хост выбирает музыку; гости слушают синхронно и могут предлагать треки.', + helpHostOnly: '(хост)', + helpSec1Title: 'Что такое Orbit?', + helpSec1Body: 'Режим «слушать вместе» — хост управляет воспроизведением, гости подключаются. Всё идёт через плейлисты на твоём собственном сервере Navidrome; никакой внешней инфраструктуры.', + helpSec2Title: 'Хост и гости', + helpSec2Body: 'Хост управляет воспроизведением (play / pause / skip / seek) и решает, когда завершить сессию. Гости следуют за воспроизведением хоста, могут предлагать треки и выходить или присоединяться в любое время. Только хост может исключать или навсегда банить участников.', + helpSec2WarnHead: 'Важно: отдельные аккаунты.', + helpSec2WarnBody: 'У каждого участника должен быть свой аккаунт Navidrome. Если хост и гость входят под одним и тем же пользователем, их отправки конфликтуют, и хост никогда не увидит предложения гостя.', + helpSec3Title: 'Запуск и приглашение', + helpSec3Body: 'Нажми кнопку «Orbit» → Создать сессию → стартовое окно покажет готовую ссылку для копирования и опционально позволит очистить текущую очередь. Делись ссылкой как угодно. Пока сессия активна, кнопка «Поделиться» в верхней панели сессии копирует ссылку в любой момент.', + helpSec4Title: 'Присоединение', + helpSec4Body: 'Вставь ссылку-приглашение в любом месте Psysonic с помощью Ctrl+V / Cmd+V — запрос на присоединение появится автоматически. Альтернатива: «Orbit» → Присоединиться к сессии → вставь в поле. Если ссылка указывает на сервер, где у тебя есть аккаунт, Psysonic переключит тебя автоматически. Если у тебя несколько аккаунтов на этом сервере, небольшой выбор спросит, какой использовать.', + helpSec5Title: 'Предложение треков', + helpSec5Body: 'В любом списке треков: двойной клик по строке или правый клик → «Добавить в сессию Orbit». Одиночный клик показывает подсказку вместо того, чтобы забрасывать весь альбом в общую очередь — это намеренно. Явные массовые действия вроде «Воспроизвести всё» или «Воспроизвести альбом» сначала запрашивают подтверждение, потом добавляют (никогда не заменяют).', + helpSec6Title: 'Общая очередь', + helpSec6Body: 'Очередь показывает предстоящие треки хоста — видно всем. Входящие массовые добавления всегда идут в конец, чтобы не потерять предложения гостей. Автоперемешивание периодически переупорядочивает очередь (настраивается: 1 / 5 / 10 / 15 / 30 мин). Если твоё воспроизведение отклоняется от хоста, кнопка «Догнать» в панели сессии возвращает тебя к live-позиции хоста.', + helpSec7Title: 'Подтверждения', + helpSec7Body: 'По умолчанию предложения гостей требуют ручного подтверждения — они появляются в полосе «Подтверждения» вверху панели очереди с кнопками принять / отклонить. В настройках сессии можно включить автоматическое подтверждение, и всё будет попадать сразу.', + helpSec8Title: 'Участники и настройки', + helpSec8Body: 'Открой иконку настроек в панели сессии для частоты перемешивания, автоподтверждения и быстрого «Перемешать сейчас». Нажми на количество участников, чтобы увидеть, кто подключён — исключить (может присоединиться снова по ссылке) или забанить (заблокирован для этой сессии). Гости тоже видят список участников, но только для чтения.', + helpSec9Title: 'Завершение сессии', + helpSec9Body: 'X хоста → диалог подтверждения → сессия закрывается для всех, серверные плейлисты очищаются автоматически. X гостя → гость выходит, сессия продолжается. Если хост молчит 5 минут, гость автоматически выходит с уведомлением; короткие переподключения в этом окне невидимы.', + participantsInviteLabel: 'Ссылка-приглашение', + participantsCountLabel: '{{count}} в сессии', + participantsHost: 'хост', + participantsEmpty: 'Пока нет гостей', + participantsKickTooltip: 'Удалить из сессии', + participantsKickAria: 'Удалить {{user}}', + participantsRemoveTooltip: 'Удалить из сессии', + participantsRemoveAria: 'Удалить {{user}} из этой сессии', + participantsBanTooltip: 'Забанить навсегда', + participantsBanAria: 'Забанить {{user}} навсегда', + participantsMuteTooltip: 'Запретить предлагать треки', + participantsUnmuteTooltip: 'Разрешить предлагать треки', + participantsMuteAria: 'Запретить {{user}} предлагать треки', + participantsUnmuteAria: 'Снова разрешить {{user}} предлагать треки', + confirmRemoveTitle: 'Удалить из сессии?', + confirmRemoveBody: '{{user}} будет удалён из сессии, но может присоединиться снова по твоей ссылке-приглашению.', + confirmRemoveConfirm: 'Удалить', + confirmBanTitle: 'Забанить навсегда?', + confirmBanBody: '{{user}} будет удалён и заблокирован от повторного присоединения к этой сессии. Отменить нельзя на время сессии.', + confirmBanConfirm: 'Забанить', + confirmCancel: 'Отмена', + confirmJoinTitle: 'Присоединиться к сессии Orbit?', + confirmJoinBody: '{{host}} пригласил тебя в «{{name}}». Присоединиться к сессии?', + confirmJoinConfirm: 'Присоединиться', + confirmLeaveTitle: 'Покинуть сессию?', + confirmLeaveBody: 'Покинуть «{{name}}»? Ты можешь присоединиться снова в любое время по ссылке-приглашению от {{host}}.', + confirmLeaveConfirm: 'Покинуть', + confirmEndTitle: 'Завершить сессию для всех?', + confirmEndBody: '«{{name}}» закроется для всех участников. Плейлисты сессии очищаются автоматически.', + confirmEndConfirm: 'Завершить сессию', + invalidLinkTitle: 'Ссылка-приглашение больше недействительна', + invalidLinkBody: 'Эта сессия Orbit больше не существует или уже завершена. Попроси хоста прислать новую ссылку.', + settingsTitle: 'Настройки сессии', + settingAutoApprove: 'Автоматически принимать предложения', + settingAutoApproveHint: 'Предложения гостей сразу попадают в твою очередь. Выкл.: они остаются в списке сессии для последующего просмотра.', + settingAutoShuffle: 'Автоперемешивание', + settingAutoShuffleHint: 'Периодическое Fisher-Yates перемешивание предстоящей очереди. Выкл.: порядок меняется только при ручной перестановке.', + settingShuffleInterval: 'Перемешивать каждые', + settingShuffleIntervalHint: 'Как часто предстоящая очередь перемешивается во время сессии.', + suggestBlockedMuted: 'Хост отключил тебе предложение треков на этой сессии.', + settingShuffleIntervalValue_one: '{{count}} мин', + settingShuffleIntervalValue_few: '{{count}} мин', + settingShuffleIntervalValue_many: '{{count}} мин', + settingShuffleIntervalValue_other: '{{count}} мин', + settingShuffleNow: 'Перемешать сейчас', + toastSuggested: '{{user}} предложил «{{title}}»', + toastSuggestedMany: '{{count}} новых предложений в очереди', + toastShuffled: 'Очередь перемешана', + toastJoined: 'Присоединился к сессии', + toastLoginFirst: 'Войди, прежде чем присоединяться к сессии', + toastSwitchServer: 'Сначала переключись на {{url}}, потом вставь снова', + toastNoAccountForServer: 'У тебя нет доступа к {{url}}. Попроси хоста прислать приглашение.', + toastSwitchFailed: 'Не удалось переключиться на {{url}}', + accountPickerTitle: 'Какой аккаунт?', + accountPickerSub: 'У тебя несколько аккаунтов для {{url}}. Выбери тот, под которым хочешь присоединиться.', + toastJoinFail: 'Не удалось присоединиться к сессии', + joinErrNotFound: 'Сессия не найдена', + joinErrEnded: 'Сессия завершена', + joinErrFull: 'Сессия заполнена', + joinErrKicked: 'Ты не можешь присоединиться к этой сессии', + joinErrNoUser: 'Нет активного сервера', + joinErrServerError: 'Не удалось присоединиться', + ctxAddToSession: 'Добавить в сессию Orbit', + ctxSuggestedToast: 'Предложено в сессию', + ctxSuggestFailed: 'Не удалось предложить — не присоединён', + ctxAddToSessionHost: 'Добавить в сессию Orbit', + ctxAddedHostToast: 'Добавлено в сессию', + ctxAddHostFailed: 'Не удалось добавить в сессию', + bulkConfirmTitle: 'Добавить всё в очередь Orbit?', + bulkConfirmBody: 'Это сбросит {{count}} треков в общую очередь за раз. Это много для твоих гостей. Продолжить?', + bulkConfirmYes: 'Добавить всё', + bulkConfirmNo: 'Отмена', + guestLive: 'Live', + guestPlaying: 'Сейчас играет', + guestPaused: 'Пауза', + guestLoading: 'Загрузка…', + guestSuggestions: 'Предложения', + guestUpNext: 'Далее', + guestUpNextEmpty: 'В очереди пусто. Открой контекстное меню трека и выбери «Добавить в сессию Orbit», чтобы предложить что-то.', + guestPendingTitle: 'Ожидание хоста', + guestPendingHint: 'Предложено — появится в очереди, когда хост примет.', + approvalTitle: 'Ожидают подтверждения', + approvalFrom: 'Предложено {{user}}', + approvalAccept: 'Принять', + approvalDecline: 'Отклонить', + guestUpNextMore: '+ ещё {{count}} в очереди хоста', + guestSubmitter: 'от {{user}}', + queueAddedByHost: 'Добавил хост', + queueAddedByYou: 'Добавил(а) ты', + queueAddedByUser: 'Добавил {{user}}', + guestEmpty: 'Предложений пока нет. Открой контекстное меню трека и выбери «Добавить в сессию Orbit».', + guestFooter: 'Хост управляет воспроизведением — ты не можешь изменять список.', + exitKickedTitle: 'Тебя забанили в сессии', + exitRemovedTitle: 'Тебя удалили из сессии', + exitEndedTitle: 'Хост завершил сессию', + exitHostTimeoutTitle: 'Хост недоступен', + exitHostTimeoutBody: '{{host}} давно не присылал обновления, поэтому «{{name}}» закрыта на твоей стороне. Ты можешь присоединиться снова в любое время по ссылке.', + exitKickedBody: '{{host}} забанил тебя в «{{name}}». Ты не можешь присоединиться снова.', + exitRemovedBody: '{{host}} удалил тебя из «{{name}}». Ты можешь присоединиться снова в любое время по ссылке.', + exitEndedBody: '«{{name}}» завершена. Надеюсь, тебе понравилось.', + exitOk: 'ОК', + }, }; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c0331564..780c2307 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -21,6 +21,7 @@ export const zhTranslation = { cancelDownload: '取消下载', offlineLibrary: '离线音乐库', genres: '流派', + tracks: '曲目', playlists: '播放列表', mostPlayed: '最常播放', radio: '网络电台', @@ -40,6 +41,7 @@ export const zhTranslation = { mostPlayed: '最常播放', recentlyPlayed: '最近播放', discover: '发现', + discoverSongs: '发现曲目', loadMore: '加载更多', discoverMore: '发现更多', discoverArtists: '发现艺术家', @@ -176,6 +178,7 @@ export const zhTranslation = { }, albumDetail: { back: '返回', + orbitDoubleClickHint: '双击将此曲目添加到 Orbit 队列', playAll: '全部播放', shareAlbum: '分享专辑', enqueue: '加入队列', @@ -375,6 +378,20 @@ export const zhTranslation = { offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…', offlineFailed: '添加 {{name}} 离线失败', }, + tracks: { + title: '曲目', + subtitle: '浏览。搜索。发现。', + heroEyebrow: '此刻精选', + heroReroll: '换一首', + playSong: '播放', + enqueueSong: '加入队列', + railRandom: '随机精选', + browseTitle: '浏览所有曲目', + browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。', + searchPlaceholder: '按标题、艺人或专辑搜索…', + count_one: '{{count}} 首曲目', + count_other: '{{count}} 首曲目', + }, artists: { title: '艺术家', search: '搜索…', @@ -654,6 +671,8 @@ export const zhTranslation = { hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", showArtistImages: '显示艺术家图片', showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。', + showOrbitTrigger: '在顶栏显示"Orbit"', + showOrbitTriggerDesc: '用于启动或加入共享收听会话的顶栏按钮。如果不使用 Orbit,可以隐藏 — 随时可在此重新开启。', showTrayIcon: '显示托盘图标', showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。', minimizeToTray: '最小化到托盘', @@ -1463,4 +1482,183 @@ export const zhTranslation = { liveSearch: '实时', randomAlbumsLabel: '随机专辑', }, + orbit: { + triggerLabel: 'Orbit', + triggerTooltip: '开始或加入共享收听会话', + launchCreate: '创建会话', + launchJoin: '加入会话', + launchHelp: '这是怎么工作的?', + launchHelpSoon: '即将推出', + joinModalTitle: '加入 Orbit 会话', + joinModalSub: '粘贴主持人发给你的邀请链接。', + joinModalLinkLabel: '邀请链接', + joinModalLinkPlaceholder: 'psysonic2-…', + joinModalLinkHelper: '适用于任何有效的 Orbit 链接。必须指向你当前登录的服务器。', + joinModalPasteTooltip: '从剪贴板粘贴', + joinModalSubmit: '加入', + joinModalBusy: '加入中…', + joinErrEmpty: '请粘贴一个邀请链接。', + joinErrInvalid: '这看起来不像 Orbit 邀请链接。', + closeAria: '关闭', + heroTitlePrefix: '一起听音乐 —', + heroTitleBrand: 'Orbit', + heroSub: '开始会话 — {{server}} 上的其他用户将一起收听并可以推荐曲目。', + fallbackServer: '此服务器', + tipLan: '你当前通过本地网络地址连接。网络外的访客无法加入 — 请在启动前切换到公共服务器地址。', + tipRemote: '为了让家庭网络外的访客能够加入,你的服务器地址必须公开可达。如果你的服务器仅限内部使用,请在启动前切换。', + labelName: '会话名称', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: '新建议', + reshuffleAria: '建议新名称', + helperName: '会话对访客的显示方式 — 保留或输入自己的名称。', + labelMax: '最大访客数', + helperMax: '你不计算在内 — 这仅限制加入的访客。', + labelLink: '邀请链接', + tooltipCopied: '已复制', + tooltipCopy: '复制', + ariaCopyLink: '复制邀请链接', + helperLink: '将此链接发送给你的访客。他们可以在 Psysonic 的任何位置使用 Ctrl+V 粘贴。', + labelClearQueue: '先清空我的队列', + helperClearQueue: '以空队列启动会话。关闭:你已排队的曲目保留并与访客共享。', + btnCancel: '取消', + btnStarting: '启动中…', + btnStart: '启动 Orbit', + btnCopyAndStart: '复制链接并启动', + errNameRequired: '请为你的会话命名。', + errStartFailed: '无法启动。', + hostLabel: '主持人:{{name}}', + participantsTooltip: '参与者', + settingsTooltip: '会话设置', + shareTooltip: '分享邀请链接', + shuffleLabel: '下次洗牌', + catchUpLabel: '跟上', + catchUpTooltip: '跳到主持人当前位置', + hostAway: '主持人离线', + hostOnline: '主持人在线', + endTooltip: '结束会话', + leaveTooltip: '离开会话', + helpTooltip: 'Orbit 的工作原理', + helpTitle: 'Orbit 的工作原理', + helpIntro: 'Orbit 将 Psysonic 变成一个共享的收听空间。主持人选择音乐;访客同步收听并可以推荐曲目。', + helpHostOnly: '(主持人)', + helpSec1Title: '什么是 Orbit?', + helpSec1Body: '"一起听"模式 — 主持人驱动播放,访客加入收听。一切都通过你自己的 Navidrome 服务器上的播放列表进行;没有外部基础设施。', + helpSec2Title: '主持人和访客', + helpSec2Body: '主持人控制播放(播放 / 暂停 / 跳过 / 跳转),并决定会话何时结束。访客跟随主持人的播放,可以推荐曲目,随时离开或重新加入。只有主持人可以踢出或永久封禁参与者。', + helpSec2WarnHead: '重要:使用独立账户。', + helpSec2WarnBody: '每个参与者都需要自己的 Navidrome 账户。如果主持人和访客使用同一用户登录,他们的提交会冲突,主持人将永远看不到访客的建议。', + helpSec3Title: '启动和邀请', + helpSec3Body: '点击"Orbit"按钮 → 创建会话 → 启动弹窗显示可复制的邀请链接,并可选清空你当前的队列。以任何方式分享该链接。会话运行时,顶部会话栏中的分享按钮可随时再次复制链接。', + helpSec4Title: '加入', + helpSec4Body: '使用 Ctrl+V / Cmd+V 在 Psysonic 的任何位置粘贴邀请链接 — 加入提示会自动弹出。或者:「Orbit」→ 加入会话 → 粘贴到字段中。如果链接指向你有账户的服务器,Psysonic 会为你切换。当你在该服务器上有多个账户时,小型选择器会询问使用哪个。', + helpSec5Title: '推荐曲目', + helpSec5Body: '在任何曲目列表中:双击一行,或右键 → "添加到 Orbit 会话"。单击会显示提示而不是将整张专辑扔进共享队列 — 这是有意的。明确的批量操作(如"全部播放"或"播放专辑")先要求确认然后追加(永不替换)。', + helpSec6Title: '共享队列', + helpSec6Body: '队列显示主持人即将播放的曲目 — 所有人可见。批量添加总是追加到末尾,因此访客的建议不会丢失。自动洗牌定期重新排序队列(可配置:1 / 5 / 10 / 15 / 30 分钟)。如果你的播放偏离主持人,会话栏中的"跟上"按钮会让你回到主持人的实时位置。', + helpSec7Title: '审批', + helpSec7Body: '默认情况下,访客建议需要手动审批 — 它们出现在队列面板顶部的"审批"栏中,带有接受 / 拒绝按钮。可以在会话设置中打开自动审批,使所有建议直接生效。', + helpSec8Title: '参与者和设置', + helpSec8Body: '打开会话栏中的设置图标以配置洗牌频率、自动审批和"立即洗牌"快捷方式。点击参与者数量查看谁已连接 — 踢出(可通过链接重新加入)或封禁(在该会话中被锁定)。访客也可以看到参与者列表,但仅为只读。', + helpSec9Title: '结束会话', + helpSec9Body: '主持人 X → 确认对话框 → 会话对所有人关闭,服务器播放列表会自动清理。访客 X → 访客离开,会话继续运行。如果主持人保持静默 5 分钟,访客会自动退出并收到通知;该窗口内的短时间重连不可见。', + participantsInviteLabel: '邀请链接', + participantsCountLabel: '{{count}} 人在会话中', + participantsHost: '主持人', + participantsEmpty: '还没有访客', + participantsKickTooltip: '从会话中移除', + participantsKickAria: '移除 {{user}}', + participantsRemoveTooltip: '从会话中移除', + participantsRemoveAria: '从此会话中移除 {{user}}', + participantsBanTooltip: '永久封禁', + participantsBanAria: '永久封禁 {{user}}', + participantsMuteTooltip: '禁止投歌', + participantsUnmuteTooltip: '允许投歌', + participantsMuteAria: '禁止 {{user}} 投歌', + participantsUnmuteAria: '允许 {{user}} 重新投歌', + confirmRemoveTitle: '从会话中移除?', + confirmRemoveBody: '{{user}} 将从会话中移除,但可以通过你的邀请链接重新加入。', + confirmRemoveConfirm: '移除', + confirmBanTitle: '永久封禁?', + confirmBanBody: '{{user}} 将被移除并被阻止重新加入此会话。在会话存在期间无法撤销。', + confirmBanConfirm: '封禁', + confirmCancel: '取消', + confirmJoinTitle: '加入 Orbit 会话?', + confirmJoinBody: '{{host}} 邀请你加入"{{name}}"。加入会话?', + confirmJoinConfirm: '加入', + confirmLeaveTitle: '离开会话?', + confirmLeaveBody: '离开"{{name}}"?你可以随时通过 {{host}} 的邀请链接重新加入。', + confirmLeaveConfirm: '离开', + confirmEndTitle: '为所有人结束会话?', + confirmEndBody: '"{{name}}"将对所有参与者关闭。会话的播放列表会自动清理。', + confirmEndConfirm: '结束会话', + invalidLinkTitle: '邀请链接已失效', + invalidLinkBody: '此 Orbit 会话不再存在或已结束。请主持人提供新的邀请链接。', + settingsTitle: '会话设置', + settingAutoApprove: '自动批准建议', + settingAutoApproveHint: '访客建议立即进入你的队列。关闭:它们保留在会话列表中供稍后审查。', + settingAutoShuffle: '自动洗牌', + settingAutoShuffleHint: '即将播放队列的定期 Fisher-Yates 洗牌。关闭:顺序仅在你手动重新排列时改变。', + settingShuffleInterval: '洗牌间隔', + settingShuffleIntervalHint: '会话运行时即将播放队列的重新洗牌频率。', + suggestBlockedMuted: '主持人已禁止你在本场会话投歌。', + settingShuffleIntervalValue_one: '{{count}} 分钟', + settingShuffleIntervalValue_other: '{{count}} 分钟', + settingShuffleNow: '立即洗牌', + toastSuggested: '{{user}} 推荐了"{{title}}"', + toastSuggestedMany: '队列中有 {{count}} 个新建议', + toastShuffled: '队列已洗牌', + toastJoined: '已加入会话', + toastLoginFirst: '加入会话前请先登录', + toastSwitchServer: '请先切换到 {{url}},然后再次粘贴', + toastNoAccountForServer: '你没有 {{url}} 的访问权限。请主持人发送邀请。', + toastSwitchFailed: '无法切换到 {{url}}', + accountPickerTitle: '使用哪个账户?', + accountPickerSub: '你在 {{url}} 上有多个账户。选择用于加入会话的账户。', + toastJoinFail: '无法加入会话', + joinErrNotFound: '未找到会话', + joinErrEnded: '会话已结束', + joinErrFull: '会话已满', + joinErrKicked: '你无法重新加入此会话', + joinErrNoUser: '没有活动服务器', + joinErrServerError: '无法加入', + ctxAddToSession: '添加到 Orbit 会话', + ctxSuggestedToast: '已推荐到会话', + ctxSuggestFailed: '无法推荐 — 未加入', + ctxAddToSessionHost: '添加到 Orbit 会话', + ctxAddedHostToast: '已添加到会话', + ctxAddHostFailed: '无法添加到会话', + bulkConfirmTitle: '全部添加到 Orbit 队列?', + bulkConfirmBody: '这将一次性把 {{count}} 首曲目放入共享队列。对你的访客来说信息量很大。继续?', + bulkConfirmYes: '全部添加', + bulkConfirmNo: '取消', + guestLive: '直播', + guestPlaying: '正在播放', + guestPaused: '已暂停', + guestLoading: '加载中…', + guestSuggestions: '建议', + guestUpNext: '接下来', + guestUpNextEmpty: '队列为空。打开曲目的上下文菜单并选择"添加到 Orbit 会话"来推荐一首。', + guestPendingTitle: '等待主持人', + guestPendingHint: '已推荐 — 主持人接受后会出现在队列中。', + approvalTitle: '待批准', + approvalFrom: '由 {{user}} 推荐', + approvalAccept: '接受', + approvalDecline: '拒绝', + guestUpNextMore: '+ 主持人队列中还有 {{count}} 首', + guestSubmitter: '来自 {{user}}', + queueAddedByHost: '由主持人添加', + queueAddedByYou: '由你添加', + queueAddedByUser: '由 {{user}} 添加', + guestEmpty: '还没有建议。打开曲目的上下文菜单并选择"添加到 Orbit 会话"。', + guestFooter: '主持人控制播放 — 你无法更改列表。', + exitKickedTitle: '你已被会话封禁', + exitRemovedTitle: '你已从会话中移除', + exitEndedTitle: '主持人已结束会话', + exitHostTimeoutTitle: '主持人无响应', + exitHostTimeoutBody: '{{host}} 有一段时间没有发送更新了,所以"{{name}}"在你这边已关闭。你可以随时通过邀请链接重新加入。', + exitKickedBody: '{{host}} 已将你从"{{name}}"封禁。你无法重新加入。', + exitRemovedBody: '{{host}} 已将你从"{{name}}"移除。你可以随时通过邀请链接重新加入。', + exitEndedBody: '"{{name}}"已结束。希望你玩得开心。', + exitOk: '好的', + }, }; diff --git a/src/main.tsx b/src/main.tsx index 656a4a30..4de98ddf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import './i18n'; import './styles/theme.css'; import './styles/layout.css'; import './styles/components.css'; +import './styles/tracks.css'; // Expose the Tauri window label synchronously so App() can pick its root // component (main app vs mini player) on first render without flicker. diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 8ee5c83c..27b84ce6 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,18 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Play, SlidersVertical } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SlidersVertical } from 'lucide-react'; import { - search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, + search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs, SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong, } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import CustomSelect from '../components/CustomSelect'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; -import { useShallow } from 'zustand/react/shallow'; type ResultType = 'all' | 'artists' | 'albums' | 'songs'; @@ -34,23 +32,6 @@ export default function AdvancedSearch() { const { t } = useTranslation(); const [params] = useSearchParams(); const qFromUrl = params.get('q') ?? ''; - const navigate = useNavigate(); - const psyDrag = useDragDrop(); - - const { playTrack, openContextMenu } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - openContextMenu: s.openContextMenu, - })) - ); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); - const [query, setQuery] = useState(params.get('q') ?? ''); const [genre, setGenre] = useState(''); const [yearFrom, setYearFrom] = useState(''); @@ -66,10 +47,35 @@ export default function AdvancedSearch() { const [genreNote, setGenreNote] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + // Pagination — only the free-text-query branch uses search3 with offset + const SONGS_INITIAL = 100; + const SONGS_PAGE_SIZE = 50; + const [activeSearch, setActiveSearch] = useState(null); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); + + const applySongFilters = ( + list: SubsonicSong[], + g: string, + from: number | null, + to: number | null, + ): SubsonicSong[] => { + let r = list; + if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); + if (from !== null) r = r.filter(s => !s.year || s.year >= from); + if (to !== null) r = r.filter(s => !s.year || s.year <= to); + return r; + }; + const runSearch = async (opts: SearchOpts) => { setLoading(true); setHasSearched(true); setGenreNote(false); + setActiveSearch(opts); + setSongsServerOffset(0); + setSongsHasMore(false); const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts; const from = yf ? parseInt(yf) : null; const to = yt ? parseInt(yt) : null; @@ -80,23 +86,25 @@ export default function AdvancedSearch() { try { if (q.trim()) { - const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 }); + const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL }); artists = r.artists; albums = r.albums; - songs = r.songs; + songs = applySongFilters(r.songs, g, from, to); if (g) { albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase()); - songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase()); } if (from !== null) { albums = albums.filter(a => !a.year || a.year >= from); - songs = songs.filter(s => !s.year || s.year >= from); } if (to !== null) { albums = albums.filter(a => !a.year || a.year <= to); - songs = songs.filter(s => !s.year || s.year <= to); } + + // Only the free-text branch supports server-side pagination via search3 offset. + // If the server returned a full page, more probably exist. + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); } else if (g) { const [albumRes, songRes] = await Promise.all([ rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50), @@ -131,6 +139,39 @@ export default function AdvancedSearch() { if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' }); }, [musicLibraryFilterVersion, qFromUrl]); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore) return; + if (!activeSearch || !activeSearch.query.trim()) return; + setLoadingMoreSongs(true); + try { + const q = activeSearch.query.trim(); + const g = activeSearch.genre; + const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null; + const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null; + const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset); + const filtered = applySongFilters(page, g, from, to); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev); + setSongsServerOffset(o => o + page.length); + // No more pages when the server returned a non-full page (regardless of how many survived filtering). + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset]); + + // IntersectionObserver on the bottom sentinel — fires loadMoreSongs as it nears the viewport. + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); runSearch({ query, genre, yearFrom, yearTo, resultType }); @@ -279,90 +320,22 @@ export default function AdvancedSearch() { {results && results.songs.length > 0 && (

- {t('search.songs')} ({results.songs.length}) + {t('search.songs')} {genreNote && ( — {t('search.advancedGenreNote')} )}

-
-
- - {t('randomMix.trackTitle')} - {t('randomMix.trackArtist')} - {t('randomMix.trackAlbum')} - {t('randomMix.trackGenre')} - {t('randomMix.trackDuration')} + + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => { - const track = songToTrack(song); - return ( -
playTrack(track, results.songs.map(songToTrack))} - role="row" - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, track, 'song'); - }} - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
- song.artistId && navigate(`/artist/${song.artistId}`)} - > - {song.artist} - -
-
- navigate(`/album/${song.albumId}`)} - > - {song.album} - -
-
- {song.genre ?? '—'} -
- - {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')} - -
- ); - })} -
+ )}
)}
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 8f97687f..302aaff5 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -5,6 +5,7 @@ import { invoke } from '@tauri-apps/api/core'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; @@ -153,7 +154,10 @@ const handleShuffleAll = () => { if (shuffled[0]) playTrack(shuffled[0], shuffled); }; + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); + const handlePlaySong = (song: SubsonicSong) => { + if (orbitActive) { queueHint(); return; } if (!album) return; const albumGenre = album.album.genre; const tracks = album.songs.map(s => { @@ -165,6 +169,8 @@ const handleShuffleAll = () => { playTrack(track, tracks); }; + const handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id); + const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); usePlayerStore.getState().setUserRatingOverride(songId, rating); @@ -427,6 +433,7 @@ const handleShuffleAll = () => { userRatingOverrides={userRatingOverrides} starredSongs={mergedStarredSongs} onPlaySong={handlePlaySong} + onDoubleClickSong={orbitActive ? handleDoubleClickSong : undefined} onRate={handleRate} onToggleSongStar={toggleSongStar} onContextMenu={openContextMenu} diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 0b7c94bc..1d144c32 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -6,6 +6,7 @@ import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp, Share2 } from 'lucide-react'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; @@ -74,6 +75,7 @@ export default function ArtistDetail() { const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const clearQueue = usePlayerStore(state => state.clearQueue); const openContextMenu = usePlayerStore(state => state.openContextMenu); const currentTrack = usePlayerStore(state => state.currentTrack); @@ -702,14 +704,19 @@ export default function ArtistDetail() { style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }} onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; + if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={(e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} > -
{ e.stopPropagation(); playTopSongWithContinuation(idx); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}> {currentTrack?.id === song.id && isPlaying &&
} {idx + 1} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 3a088378..bcbc7826 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -17,6 +17,7 @@ import { unstar } from '../api/subsonic'; import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; import { useSelectionStore } from '../store/selectionStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import GenreFilterBar from '../components/GenreFilterBar'; @@ -78,6 +79,7 @@ export default function Favorites() { const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const playRadio = usePlayerStore(s => s.playRadio); const stop = usePlayerStore(s => s.stop); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -629,10 +631,17 @@ export default function Favorites() { toggleSelect(song.id, i, false); } else if (inSelectMode) { toggleSelect(song.id, i, e.shiftKey); + } else if (orbitActive) { + queueHint(); } else { playTrack(track, visibleSongs.map(songToTrack)); } }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || inSelectMode) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'favorite-song'); }} role="row" onMouseDown={e => { @@ -660,7 +669,7 @@ export default function Favorites() { {visibleCols.map(colDef => { switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}> { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 86a72937..7511f10f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import Hero from '../components/Hero'; import AlbumRow from '../components/AlbumRow'; -import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic'; +import SongRail from '../components/SongRail'; +import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; @@ -13,6 +14,7 @@ import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../uti const HOME_RANDOM_FETCH = 100; const HOME_HERO_COUNT = 8; const HOME_DISCOVER_SLICE = 20; +const HOME_DISCOVER_SONGS_SIZE = 18; export default function Home() { const homeSections = useHomeStore(s => s.sections); @@ -30,6 +32,7 @@ export default function Home() { const [mostPlayed, setMostPlayed] = useState([]); const [recentlyPlayed, setRecentlyPlayed] = useState([]); const [randomArtists, setRandomArtists] = useState([]); + const [discoverSongs, setDiscoverSongs] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -41,13 +44,16 @@ export default function Home() { const albumMix = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; - const [s, n, rRaw, f, rp, artists] = await Promise.all([ + const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([ getAlbumList('starred', 12).catch(() => []), getAlbumList('newest', 12).catch(() => []), getAlbumList('random', randomSize).catch(() => []), getAlbumList('frequent', 12).catch(() => []), getAlbumList('recent', 12).catch(() => []), isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), + isVisible('discoverSongs') + ? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]) + : Promise.resolve([]), ]); if (cancelled) return; const r = await filterAlbumsByMixRatings(rRaw, mixCfg); @@ -57,6 +63,7 @@ export default function Home() { setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE)); setMostPlayed(f); setRecentlyPlayed(rp); + setDiscoverSongs(songs); const shuffled = [...artists]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -128,6 +135,12 @@ export default function Home() { moreText={t('home.discoverMore')} /> )} + {isVisible('discoverSongs') && discoverSongs.length > 0 && ( + + )} {isVisible('discoverArtists') && randomArtists.length > 0 && (
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 6bbcf91a..ec2bdca5 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -17,6 +17,7 @@ import { useOfflineJobStore } from '../store/offlineJobStore'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; @@ -250,6 +251,7 @@ export default function PlaylistDetail() { userRatingOverrides: s.userRatingOverrides, })) ); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist); @@ -1636,10 +1638,17 @@ export default function PlaylistDetail() { toggleSelect(song.id, i, false); } else if (selectedIds.size > 0) { toggleSelect(song.id, i, e.shiftKey); + } else if (orbitActive) { + queueHint(); } else { playTrack(displayedTracks[i], displayedTracks); } }} + onDoubleClick={orbitActive ? e => { + if ((e.target as HTMLElement).closest('button, a, input')) return; + if (e.ctrlKey || e.metaKey || selectedIds.size > 0) return; + addTrackToOrbit(song.id); + } : undefined} onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); @@ -1650,7 +1659,7 @@ export default function PlaylistDetail() { const inSelectMode = selectedIds.size > 0; switch (colDef.key) { case 'num': return ( -
{ e.stopPropagation(); playTrack(displayedTracks[i], displayedTracks); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}> { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index a6c2c608..35a0d06e 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -6,6 +6,7 @@ import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { fetchRandomMixSongsUntilFull, getMixMinRatingsConfigFromAuth, @@ -32,6 +33,7 @@ export default function RandomMix() { const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const playTrack = usePlayerStore(s => s.playTrack); + const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const currentTrack = usePlayerStore(s => s.currentTrack); @@ -427,7 +429,8 @@ export default function RandomMix() { key={song.id} className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }} - onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} + onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} + onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} role="row" onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} onMouseDown={e => { @@ -446,7 +449,7 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1} @@ -526,7 +529,8 @@ export default function RandomMix() { key={song.id} className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }} - onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }} + onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }} + onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined} role="row" onContextMenu={e => { e.preventDefault(); @@ -549,7 +553,7 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> +
{ e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}> {isCurrentTrack && isPlaying &&
} {idx + 1} diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index e854edc6..8229ff4f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,19 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Play, Search } from 'lucide-react'; -import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; -import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { Search } from 'lucide-react'; +import { search, searchSongsPaged, SearchResults as ISearchResults } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; +import SongRow, { SongListHeader } from '../components/SongRow'; import { useTranslation } from 'react-i18next'; -import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; -import { useThemeStore } from '../store/themeStore'; -import { useShallow } from 'zustand/react/shallow'; -function formatDuration(s: number) { - return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; -} +const SONGS_INITIAL = 50; +const SONGS_PAGE_SIZE = 50; export default function SearchResults() { const { t } = useTranslation(); @@ -21,39 +17,52 @@ export default function SearchResults() { const query = params.get('q') ?? ''; const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [songsServerOffset, setSongsServerOffset] = useState(0); + const [songsHasMore, setSongsHasMore] = useState(false); + const [loadingMoreSongs, setLoadingMoreSongs] = useState(false); + const songsSentinelRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const showBitrate = useThemeStore(s => s.showBitrate); - const psyDrag = useDragDrop(); - - const { playTrack, enqueue, openContextMenu, currentTrack } = usePlayerStore( - useShallow(s => ({ - playTrack: s.playTrack, - enqueue: s.enqueue, - openContextMenu: s.openContextMenu, - currentTrack: s.currentTrack, - })) - ); - - const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); useEffect(() => { + setSongsServerOffset(0); + setSongsHasMore(false); if (!query.trim()) { setResults(null); return; } setLoading(true); - search(query, { artistCount: 20, albumCount: 20, songCount: 50 }) - .then(r => setResults(r)) + search(query, { artistCount: 20, albumCount: 20, songCount: SONGS_INITIAL }) + .then(r => { + setResults(r); + setSongsServerOffset(r.songs.length); + setSongsHasMore(r.songs.length === SONGS_INITIAL); + }) .finally(() => setLoading(false)); }, [query, musicLibraryFilterVersion]); - const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + const loadMoreSongs = useCallback(async () => { + if (loadingMoreSongs || !songsHasMore || !query.trim()) return; + setLoadingMoreSongs(true); + try { + const page = await searchSongsPaged(query.trim(), SONGS_PAGE_SIZE, songsServerOffset); + setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...page] } : prev); + setSongsServerOffset(o => o + page.length); + if (page.length < SONGS_PAGE_SIZE) setSongsHasMore(false); + } catch { + setSongsHasMore(false); + } finally { + setLoadingMoreSongs(false); + } + }, [loadingMoreSongs, songsHasMore, query, songsServerOffset]); - const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { - playTrack(songToTrack(song), list.map(songToTrack)); - }; + useEffect(() => { + const el = songsSentinelRef.current; + if (!el) return; + const obs = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) loadMoreSongs(); + }, { rootMargin: '600px' }); + obs.observe(el); + return () => obs.disconnect(); + }, [loadMoreSongs]); + + const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); return (
@@ -83,69 +92,17 @@ export default function SearchResults() { )} {results.songs.length > 0 && ( -
-
-

{t('search.songs')}

-
-
-
-
-
{t('albumDetail.trackTitle')}
-
{t('albumDetail.trackArtist')}
-
{t('search.album')}
-
{t('albumDetail.trackFormat')}
-
{t('albumDetail.trackDuration')}
+
+

{t('search.songs')}

+ + {results.songs.map(song => ( + + ))} + {songsHasMore && ( +
+ {loadingMoreSongs &&
}
- {results.songs.map(song => ( -
playSong(song, results.songs)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - role="row" - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const track = songToTrack(song); - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > - -
- {song.title} -
-
{song.artist}
-
{song.album}
- - {[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')} - - - {formatDuration(song.duration)} - -
- ))} -
+ )}
)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index b93227a9..a9d38142 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -3394,6 +3394,17 @@ export default function Settings() {
+
+
+
+
{t('settings.showOrbitTrigger')}
+
{t('settings.showOrbitTriggerDesc')}
+
+ +
{!IS_WINDOWS && ( <>
@@ -4277,6 +4288,7 @@ function HomeCustomizer() { hero: t('home.hero'), recent: t('home.recent'), discover: t('home.discover'), + discoverSongs: t('home.discoverSongs'), discoverArtists: t('home.discoverArtists'), recentlyPlayed: t('home.recentlyPlayed'), starred: t('home.starred'), diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx new file mode 100644 index 00000000..b15ac44e --- /dev/null +++ b/src/pages/Tracks.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + SubsonicSong, + getRandomSongs, + buildCoverArtUrl, + coverArtCacheKey, +} from '../api/subsonic'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import CachedImage from '../components/CachedImage'; +import SongRail from '../components/SongRail'; +import VirtualSongList from '../components/VirtualSongList'; +import { playSongNow } from '../utils/playSong'; + +const RANDOM_RAIL_SIZE = 18; + +export default function Tracks() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const activeServerId = useAuthStore(s => s.activeServerId); + const enqueue = usePlayerStore(s => s.enqueue); + + const [hero, setHero] = useState(null); + const [heroLoading, setHeroLoading] = useState(false); + + const [random, setRandom] = useState([]); + const [randomLoading, setRandomLoading] = useState(true); + + const rerollHero = useCallback(async () => { + setHeroLoading(true); + try { + const picks = await getRandomSongs(1); + if (picks[0]) setHero(picks[0]); + } finally { + setHeroLoading(false); + } + }, []); + + const rerollRandom = useCallback(async () => { + setRandomLoading(true); + try { + const r = await getRandomSongs(RANDOM_RAIL_SIZE); + setRandom(r); + } finally { + setRandomLoading(false); + } + }, []); + + useEffect(() => { + if (!activeServerId) return; + rerollHero(); + rerollRandom(); + }, [activeServerId, rerollHero, rerollRandom]); + + const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : ''; + + // Hide the hero song from the random rail if the server happens to return it in + // both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window). + const railSongs = useMemo( + () => (hero ? random.filter(s => s.id !== hero.id) : random), + [random, hero], + ); + + return ( +
+
+
+

{t('tracks.title')}

+

{t('tracks.subtitle')}

+
+
+ + {hero && ( +
+
+ {heroCoverUrl ? ( + + ) : ( +
+ )} +
+
+ + + {t('tracks.heroEyebrow')} + +

{hero.title}

+

+ hero.artistId && navigate(`/artist/${hero.artistId}`)} + >{hero.artist} + {hero.album && ( + <> + · + hero.albumId && navigate(`/album/${hero.albumId}`)} + >{hero.album} + + )} +

+
+ + + +
+
+
+ )} + + + + +
+ ); +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index b4e58c41..5fbb5f8a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -64,6 +64,9 @@ interface AuthState { showArtistImages: boolean; showTrayIcon: boolean; minimizeToTray: boolean; + /** Whether the "Orbit" topbar trigger is rendered. Users who never + * touch Orbit can hide it so the header stays uncluttered. */ + showOrbitTrigger: boolean; discordRichPresence: boolean; enableAppleMusicCoversDiscord: boolean; /** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */ @@ -222,6 +225,7 @@ interface AuthState { setShowArtistImages: (v: boolean) => void; setShowTrayIcon: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void; + setShowOrbitTrigger: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; setEnableAppleMusicCoversDiscord: (v: boolean) => void; setEnableBandsintown: (v: boolean) => void; @@ -334,6 +338,7 @@ export const useAuthStore = create()( showArtistImages: false, showTrayIcon: true, minimizeToTray: false, + showOrbitTrigger: true, discordRichPresence: false, enableAppleMusicCoversDiscord: false, enableBandsintown: false, @@ -478,6 +483,7 @@ export const useAuthStore = create()( setShowArtistImages: (v) => set({ showArtistImages: v }), setShowTrayIcon: (v) => set({ showTrayIcon: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }), + setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), setEnableBandsintown: (v) => set({ enableBandsintown: v }), diff --git a/src/store/confirmModalStore.ts b/src/store/confirmModalStore.ts new file mode 100644 index 00000000..423003d6 --- /dev/null +++ b/src/store/confirmModalStore.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand'; + +let _resolve: ((accepted: boolean) => void) | null = null; + +interface ConfirmRequest { + title: string; + message: string; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; +} + +interface ConfirmModalStore extends ConfirmRequest { + isOpen: boolean; + request: (req: ConfirmRequest) => Promise; + confirm: () => void; + cancel: () => void; +} + +export const useConfirmModalStore = create(set => ({ + isOpen: false, + title: '', + message: '', + confirmLabel: '', + cancelLabel: undefined, + danger: false, + + request: (req) => + new Promise(resolve => { + // If a previous prompt is still pending, treat the old one as cancelled. + if (_resolve) _resolve(false); + _resolve = resolve; + set({ isOpen: true, ...req }); + }), + + confirm: () => { + _resolve?.(true); + _resolve = null; + set({ isOpen: false }); + }, + + cancel: () => { + _resolve?.(false); + _resolve = null; + set({ isOpen: false }); + }, +})); diff --git a/src/store/helpModalStore.ts b/src/store/helpModalStore.ts new file mode 100644 index 00000000..5a013599 --- /dev/null +++ b/src/store/helpModalStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +interface HelpModalStore { + isOpen: boolean; + open: () => void; + close: () => void; +} + +/** + * App-wide toggle for the Orbit help modal. Two triggers — the launch + * popover "How does this work?" entry and the in-session bar help button + * — write to the same store so they share the one rendered modal. Not + * persisted; resets to closed on reload. + */ +export const useHelpModalStore = create(set => ({ + isOpen: false, + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), +})); diff --git a/src/store/homeStore.ts b/src/store/homeStore.ts index 676750c9..62573cb7 100644 --- a/src/store/homeStore.ts +++ b/src/store/homeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed'; +export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed'; export interface HomeSectionConfig { id: HomeSectionId; @@ -12,6 +12,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [ { id: 'hero', visible: true }, { id: 'recent', visible: true }, { id: 'discover', visible: true }, + { id: 'discoverSongs', visible: true }, { id: 'discoverArtists', visible: true }, { id: 'recentlyPlayed', visible: true }, { id: 'starred', visible: true }, @@ -33,6 +34,19 @@ export const useHomeStore = create()( })), reset: () => set({ sections: DEFAULT_HOME_SECTIONS }), }), - { name: 'psysonic_home' } + { + name: 'psysonic_home', + onRehydrateStorage: () => (state) => { + // Append any sections introduced after the user first persisted their order, + // so new defaults show up without forcing a manual Reset. + if (!state) return; + const safe = (state.sections ?? []).filter( + (s): s is HomeSectionConfig => s != null && typeof s.id === 'string', + ); + const known = new Set(safe.map(s => s.id)); + const missing = DEFAULT_HOME_SECTIONS.filter(s => !known.has(s.id)); + state.sections = missing.length > 0 ? [...safe, ...missing] : safe; + }, + } ) ); diff --git a/src/store/orbitAccountPickerStore.ts b/src/store/orbitAccountPickerStore.ts new file mode 100644 index 00000000..74675dfa --- /dev/null +++ b/src/store/orbitAccountPickerStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import type { ServerProfile } from './authStore'; + +let _resolve: ((server: ServerProfile | null) => void) | null = null; + +interface OrbitAccountPickerStore { + isOpen: boolean; + accounts: ServerProfile[]; + /** Open the picker with the given candidates. Resolves with the chosen + * server or null if the user cancels. */ + request: (accounts: ServerProfile[]) => Promise; + pick: (server: ServerProfile) => void; + cancel: () => void; +} + +export const useOrbitAccountPickerStore = create(set => ({ + isOpen: false, + accounts: [], + + request: (accounts) => + new Promise(resolve => { + // If another picker is already pending, treat the previous one as cancelled. + if (_resolve) _resolve(null); + _resolve = resolve; + set({ isOpen: true, accounts }); + }), + + pick: (server) => { + _resolve?.(server); + _resolve = null; + set({ isOpen: false }); + }, + + cancel: () => { + _resolve?.(null); + _resolve = null; + set({ isOpen: false }); + }, +})); diff --git a/src/store/orbitStore.ts b/src/store/orbitStore.ts new file mode 100644 index 00000000..7c0a09ac --- /dev/null +++ b/src/store/orbitStore.ts @@ -0,0 +1,147 @@ +import { create } from 'zustand'; +import type { OrbitState } from '../api/orbit'; + +/** + * Orbit — local session store. + * + * Mirrors the remote canonical state for the UI, plus a handful of + * client-only fields (our role in the session, the playlist ids we're + * bound to, lifecycle phase). Not persisted — a session is transient by + * design; if the app restarts mid-session, we re-join on next open + * rather than resurrect stale local state. + * + * Phase 1 is intentionally thin: only `set`/`reset` plumbing so later + * phases (host/guest lifecycle, track pipeline) can drop into place + * without touching the store shape. + */ + +export type OrbitRole = 'host' | 'guest'; + +/** Fine-grained lifecycle phase. Drives which modal/indicator UI is visible. */ +export type OrbitPhase = + /** No session bound. */ + | 'idle' + /** Host: creating the session playlist and seeding state. */ + | 'starting' + /** Guest: auth + lookup before commit. */ + | 'joining' + /** Session established; polling cycle active. */ + | 'active' + /** Host ended the session; showing exit modal. */ + | 'ended' + /** Unrecoverable error (server unreachable, playlist vanished, etc.). */ + | 'error'; + +interface OrbitStore { + /** Current role in the session, or null when idle. */ + role: OrbitRole | null; + /** Active session id, or null. */ + sessionId: string | null; + /** Navidrome playlist id of the canonical session playlist. */ + sessionPlaylistId: string | null; + /** Navidrome playlist id of our own outbox (exists for both host and guest). */ + outboxPlaylistId: string | null; + /** Lifecycle phase. */ + phase: OrbitPhase; + /** Latest-known canonical state (last poll). Null while starting/joining. */ + state: OrbitState | null; + /** Human-readable error when `phase === 'error'`. */ + errorMessage: string | null; + /** + * Wall-clock ms when this client joined the current session (host: start + * time, guest: join time). Used to disambiguate stale `removed`-list + * entries from a fresh re-join after a remove. Null when idle. + */ + joinedAt: number | null; + /** + * Guest-only: track ids the local client has suggested but the host + * hasn't yet merged into the shared queue. Filled by + * `suggestOrbitTrack`, drained by the guest tick once the id appears + * in `state.queue` / `state.currentTrack`. In-memory only — a rejoin + * starts empty, any still-pending ids either land or get dropped by + * the host's next sweep anyway. + */ + pendingSuggestions: string[]; + /** + * Host-only: suggestionKeys (see suggestionKey() in utils/orbit) that + * the host has already merged into the play queue — whether via + * auto-approve or an explicit Approve button. Stops the host tick + * from re-inserting the same item on every sweep. + */ + mergedSuggestionKeys: string[]; + /** + * Host-only: suggestionKeys that the host explicitly declined. Keeps + * them out of the merge pipeline AND out of the pending-approvals UI + * so a declined suggestion doesn't keep begging for attention. + */ + declinedSuggestionKeys: string[]; + + // ── Setters (Phase 1 scaffolding; later phases add real actions) ──────── + setPhase: (phase: OrbitPhase) => void; + setRole: (role: OrbitRole | null) => void; + setSessionBinding: (args: { + sessionId: string | null; + sessionPlaylistId: string | null; + outboxPlaylistId: string | null; + }) => void; + setState: (state: OrbitState | null) => void; + setError: (message: string | null) => void; + addPendingSuggestion: (trackId: string) => void; + /** Keep only the pending ids that are NOT yet observable in the shared queue. */ + reconcilePendingSuggestions: (landedTrackIds: Set) => void; + /** Host: mark a suggestion as merged so the tick stops re-proposing it. */ + addMergedSuggestion: (key: string) => void; + /** Host: mark a suggestion as declined so the approval UI and tick ignore it. */ + addDeclinedSuggestion: (key: string) => void; + /** Tear down the session locally. Does NOT clean up remote playlists. */ + reset: () => void; +} + +const initialState = { + role: null, + sessionId: null, + sessionPlaylistId: null, + outboxPlaylistId: null, + phase: 'idle' as OrbitPhase, + state: null, + errorMessage: null, + joinedAt: null, + pendingSuggestions: [] as string[], + mergedSuggestionKeys: [] as string[], + declinedSuggestionKeys: [] as string[], +} satisfies Omit; + +export const useOrbitStore = create()((set) => ({ + ...initialState, + + setPhase: (phase) => set({ phase }), + setRole: (role) => set({ role }), + setSessionBinding: ({ sessionId, sessionPlaylistId, outboxPlaylistId }) => + set({ sessionId, sessionPlaylistId, outboxPlaylistId }), + setState: (state) => set({ state }), + setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }), + addPendingSuggestion: (trackId) => set(s => ( + s.pendingSuggestions.includes(trackId) + ? s + : { pendingSuggestions: [...s.pendingSuggestions, trackId] } + )), + reconcilePendingSuggestions: (landedTrackIds) => set(s => { + const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id)); + return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next }; + }), + addMergedSuggestion: (key) => set(s => ( + s.mergedSuggestionKeys.includes(key) + ? s + : { mergedSuggestionKeys: [...s.mergedSuggestionKeys, key] } + )), + addDeclinedSuggestion: (key) => set(s => ( + s.declinedSuggestionKeys.includes(key) + ? s + : { declinedSuggestionKeys: [...s.declinedSuggestionKeys, key] } + )), + reset: () => set({ ...initialState }), +})); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 95fbd81e..905bf9cc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -11,6 +11,9 @@ import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; import { useHotCacheStore } from './hotCacheStore'; import { onAnalysisStorageChanged } from './analysisSync'; +import { orbitBulkGuard } from '../utils/orbitBulkGuard'; +import { useOrbitStore } from './orbitStore'; +import { estimateLivePosition } from '../api/orbit'; export interface Track { id: string; @@ -182,7 +185,9 @@ interface PlayerState { setUserRatingOverride: (id: string, rating: number) => void; playRadio: (station: InternetRadioStation) => void; - playTrack: (track: Track, queue?: Track[], manual?: boolean) => void; + /** `_orbitConfirmed` is an internal bypass flag — callers outside the + * orbit bulk-gate should leave it `undefined`. */ + playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean) => void; /** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */ reseedQueueForInstantMix: (track: Track) => void; pause: () => void; @@ -207,8 +212,8 @@ interface PlayerState { setVolume: (v: number) => void; updateReplayGainForCurrentTrack: () => void; setProgress: (t: number, duration: number) => void; - enqueue: (tracks: Track[]) => void; - enqueueAt: (tracks: Track[], insertIndex: number) => void; + enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; + enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void; setRadioArtistId: (artistId: string) => void; /** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */ @@ -228,6 +233,8 @@ interface PlayerState { reorderQueue: (startIndex: number, endIndex: number) => void; removeTrack: (index: number) => void; shuffleQueue: () => void; + /** Shuffle only the tracks after the current one — leaves played history intact. */ + shuffleUpcomingQueue: () => void; toggleLastfmLove: () => void; setLastfmLoved: (v: boolean) => void; @@ -1618,7 +1625,35 @@ export const usePlayerStore = create()( }, // ── playTrack ──────────────────────────────────────────────────────────── - playTrack: (track, queue, manual = true) => { + playTrack: (track, queue, manual = true, _orbitConfirmed = false) => { + // Orbit bulk-gate: only gate when the `queue` argument *replaces* + // the current queue (Play All / Play Album / Play Playlist / Hero + // play buttons). Navigation calls — queue-row click, next(), + // previous() — pass the existing queue back through playTrack just + // to move the index; they are not bulk operations and must not + // trigger the confirm dialog (#234 regression). + if (!_orbitConfirmed && queue && queue.length > 1) { + const current = get().queue; + const sameAsCurrent = queue.length === current.length + && queue.every((t, i) => current[i]?.id === t.id); + if (!sameAsCurrent) { + void orbitBulkGuard(queue.length).then(ok => { + if (!ok) return; + // Inside an Orbit session a bulk replace would discard guest + // suggestions mid-listen. Append instead — the dialog's + // "Add them all" copy already matches that semantic. Outside + // Orbit, proceed as a normal replace. + const role = useOrbitStore.getState().role; + if (role === 'host' || role === 'guest') { + get().enqueue(queue, true); + } else { + get().playTrack(track, queue, manual, true); + } + }); + return; + } + } + // Ghost-command guard: if a gapless switch happened within 500 ms, // this playTrack call is likely a stale IPC echo — suppress it. if (Date.now() - lastGaplessSwitchTime < 500) { @@ -1824,6 +1859,50 @@ export const usePlayerStore = create()( resume: () => { clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); + + // Orbit guest: resume means "catch up to the host's live stream". + // The user hit pause at some earlier point; resuming shouldn't drop + // them back at the stale local position while the host is already + // two songs ahead. Covers PlayerBar, media keys, MPRIS — everything + // that funnels through resume(). + const orbit = useOrbitStore.getState(); + const hostState = orbit.state; + if (orbit.role === 'guest' && hostState?.isPlaying && hostState.currentTrack) { + const trackId = hostState.currentTrack.trackId; + const targetMs = estimateLivePosition(hostState, Date.now()); + const targetSec = Math.max(0, targetMs / 1000); + const localTrackId = get().currentTrack?.id; + void (async () => { + try { + const song = await getSong(trackId); + if (!song) return; + const track = songToTrack(song); + const fraction = Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration))); + if (localTrackId === trackId) { + // Same track: seek + un-pause via the Rust engine directly. + // Bypasses this resume() branch re-entry via the early return below. + get().seek(fraction); + if (isAudioPaused) { + invoke('audio_resume').catch(console.error); + isAudioPaused = false; + set({ isPlaying: true }); + } else { + set({ isPlaying: true }); + } + } else { + // Host has a different track — load it (`_orbitConfirmed=true` + // skips the bulk gate; single-track play isn't a bulk replace + // anyway). Seek after a short defer once the engine loads. + get().playTrack(track, [track], false, true); + window.setTimeout(() => { + if (get().currentTrack?.id === trackId) get().seek(fraction); + }, 400); + } + } catch { /* silent */ } + })(); + return; + } + if (get().currentRadio) { radioAudio.play().catch(console.error); set({ isPlaying: true }); @@ -2175,7 +2254,13 @@ export const usePlayerStore = create()( }, // ── queue management ───────────────────────────────────────────────────── - enqueue: (tracks) => { + enqueue: (tracks, _orbitConfirmed = false) => { + if (!_orbitConfirmed && tracks.length > 1) { + void orbitBulkGuard(tracks.length).then(ok => { + if (ok) get().enqueue(tracks, true); + }); + return; + } set(state => { // Insert before the first upcoming auto-added track so the // "Added automatically" separator always stays at the boundary. @@ -2218,7 +2303,13 @@ export const usePlayerStore = create()( }); }, - enqueueAt: (tracks, insertIndex) => { + enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => { + if (!_orbitConfirmed && tracks.length > 1) { + void orbitBulkGuard(tracks.length).then(ok => { + if (ok) get().enqueueAt(tracks, insertIndex, true); + }); + return; + } set(state => { const idx = Math.max(0, Math.min(insertIndex, state.queue.length)); const newQueue = [ @@ -2271,6 +2362,22 @@ export const usePlayerStore = create()( syncQueueToServer(result, currentTrack, get().currentTime); }, + shuffleUpcomingQueue: () => { + const { queue, queueIndex, currentTrack } = get(); + const upcomingStart = queueIndex + 1; + const upcomingCount = queue.length - upcomingStart; + if (upcomingCount < 2) return; + const head = queue.slice(0, upcomingStart); + const upcoming = queue.slice(upcomingStart); + for (let i = upcoming.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; + } + const result = [...head, ...upcoming]; + set({ queue: result }); + syncQueueToServer(result, currentTrack, get().currentTime); + }, + removeTrack: (index) => { const { queue, queueIndex } = get(); const newQueue = [...queue]; diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index 0ac5aeca..82eff057 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -12,6 +12,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'mainstage', visible: true }, { id: 'newReleases', visible: true }, { id: 'allAlbums', visible: true }, + { id: 'tracks', visible: true }, { id: 'randomPicker', visible: true }, { id: 'randomMix', visible: true }, { id: 'randomAlbums', visible: true }, diff --git a/src/styles/components.css b/src/styles/components.css index b088fe17..8d8368b6 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11486,3 +11486,1398 @@ html[data-psy-native-hidden="true"] *::after { .np-dash-stats-values { gap: 16px; } .np-dash-stat-value { font-size: 18px; } } + +/* ───────────────────────────────────────────────────────────────────── + Orbit — session top strip + ───────────────────────────────────────────────────────────────────── */ + +.orbit-bar { + position: relative; + z-index: 10; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 6px 16px; + margin: 0 12px 8px; + background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated, rgba(255,255,255,0.04))); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + border-radius: var(--radius-md, 10px); + 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); + flex-shrink: 0; +} + +@keyframes orbit-bar-in { + from { opacity: 0; transform: translateY(-6px); } + 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 { + display: inline-flex; + align-items: center; + gap: 7px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-weight: 500; + padding: 4px 12px; + 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); + white-space: nowrap; +} +.orbit-bar__shuffle-icon { + color: var(--accent); + flex-shrink: 0; +} +.orbit-bar__shuffle-time { + color: var(--text-primary); + font-weight: 600; +} + +.orbit-bar__right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.orbit-bar__catchup { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 999px; + background: var(--accent); + border: 1px solid var(--accent); + color: #fff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + 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 { + 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; + 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, border-color 150ms ease; +} +.orbit-bar__settings svg { + transition: transform 300ms ease; +} +.orbit-bar__settings:hover { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.orbit-bar__settings:hover svg { + transform: rotate(45deg); +} + +.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); +} + +/* (Previously pushed the shell down for a fixed top-bar. Not needed — bar is + now an inline element within .main-content.) */ + +/* Participant-count button (the "4/10" in the bar) */ +.orbit-bar .orbit-bar__count { + background: none; + border: none; + color: var(--text-muted); + font: inherit; + letter-spacing: inherit; + padding: 2px 6px; + border-radius: 999px; + cursor: pointer; + transition: color 150ms ease, background 150ms ease; +} +.orbit-bar .orbit-bar__count:hover { + color: var(--text-primary); + background: color-mix(in srgb, var(--text-primary) 6%, transparent); +} + +/* ── Participants popover ──────────────────────────────────────────── */ +.orbit-participants-pop { + min-width: 280px; + max-width: 380px; + padding: 6px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +/* Share popover (host-only) — standalone counterpart to the participants popover. */ +.orbit-share-pop { + min-width: 320px; + max-width: 380px; + padding: 12px 14px 14px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +.orbit-share-pop__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 8px; +} + +.orbit-share-pop__row { + display: flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + border-radius: var(--radius-sm); + padding: 6px 6px 6px 8px; +} + +.orbit-share-pop__link { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-primary); + background: transparent; + border: none; + padding: 0; + user-select: all; +} + +.orbit-share-pop__copy { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: var(--accent); + color: var(--bg-primary, #0b0b13); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: filter 0.15s ease; +} +.orbit-share-pop__copy:hover { filter: brightness(1.1); } + +.orbit-participants-pop__head { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + padding: 6px 8px 4px; +} + +.orbit-participants-pop__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-primary); +} +.orbit-participants-pop__row:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); } + +.orbit-participants-pop__row--host { color: var(--accent); } +.orbit-participants-pop__row--host svg { color: var(--accent); } +/* Guest rows — give the user icon a quieter tone so the host's crown stays + the accent focal point. */ +.orbit-participants-pop__row:not(.orbit-participants-pop__row--host) > svg { + color: var(--text-muted); + flex-shrink: 0; +} + +.orbit-participants-pop__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.orbit-participants-pop__meta { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.orbit-participants-pop__empty { + padding: 10px 8px; + font-size: 12px; + color: var(--text-muted); + font-style: italic; + text-align: center; +} + +.orbit-participants-pop__actions { + display: inline-flex; + align-items: center; + gap: 4px; +} +.orbit-participants-pop__kick { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 140ms ease, background 140ms ease, border-color 140ms ease; +} +.orbit-participants-pop__kick:hover { + color: var(--ctp-yellow, #f9e2af); + background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 12%, transparent); + border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 35%, transparent); +} +.orbit-participants-pop__kick--ban:hover { + color: var(--ctp-red, #f38ba8); + background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent); + border-color: color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent); +} +.orbit-participants-pop__kick.is-active { + color: var(--ctp-peach, #fab387); + background: color-mix(in srgb, var(--ctp-peach, #fab387) 16%, transparent); + border-color: color-mix(in srgb, var(--ctp-peach, #fab387) 45%, transparent); +} + +/* ── Exit modal ────────────────────────────────────────────────────── */ +.orbit-exit-overlay { + align-items: center; + justify-content: center; +} + +.orbit-exit-modal { + max-width: 400px; + padding: 24px 26px 20px; + text-align: center; +} + +.orbit-exit-modal__title { + margin: 0 0 10px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.orbit-exit-modal__body { + margin: 0 0 20px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} + +.orbit-exit-modal__actions { + display: flex; + justify-content: center; +} + +/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */ +.orbit-start-trigger { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--ctp-surface1)); + transition: color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 200ms ease; +} +.orbit-start-trigger__spin { transition: transform 260ms ease; } +.orbit-start-trigger:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); + box-shadow: 0 0 12px -3px color-mix(in srgb, var(--accent) 55%, transparent); +} +.orbit-start-trigger:hover .orbit-start-trigger__spin { transform: rotate(45deg); } + +/* Launch popover — three-option menu anchored below the topbar trigger. */ +.orbit-launch-pop { + min-width: 220px; + padding: 6px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + gap: 2px; +} +.orbit-launch-pop__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} +.orbit-launch-pop__item svg { + color: var(--accent); + flex-shrink: 0; +} +.orbit-launch-pop__item:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.orbit-launch-pop__item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Orbit help modal — 9 accordions on SettingsSubSection, scrollable body. */ +.orbit-help-modal { + max-width: 560px; + width: min(560px, calc(100vw - 32px)); + max-height: 85vh; + padding: 22px 26px 20px; + display: flex; + flex-direction: column; + position: relative; +} +.orbit-help-modal__title { + margin: 0 0 8px; + padding-right: 1.5rem; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} +.orbit-help-modal__intro { + margin: 0 0 14px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.55; +} +.orbit-help-modal__body { + flex: 1; + overflow-y: auto; + margin: 0 -4px; + padding: 0 4px; +} +.orbit-help-modal__body p { + margin: 0 0 8px; + font-size: 12.5px; + line-height: 1.55; + color: var(--text-secondary, var(--text-primary)); +} +.orbit-help-modal__body p:last-child { margin-bottom: 0; } + +/* Keyboard focus indicator for accordion summaries — the default outline + is too subtle against the modal's dark background. Uses focus-visible + so mouse clicks don't leave a lingering ring. */ +.orbit-help-modal__body summary:focus-visible { + outline: none; + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: inset 3px 0 0 var(--accent); +} +.orbit-help-modal__warn { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; + padding: 10px 12px; + background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent); + border-radius: var(--radius-sm); + font-size: 12px; + line-height: 1.5; + color: var(--text-primary); +} +.orbit-help-modal__warn strong { + color: var(--ctp-yellow, #f9e2af); + font-weight: 700; +} + +/* Account picker — modal for multi-account server switch on Orbit join. */ +.orbit-account-picker { + max-width: 420px; + width: min(420px, calc(100vw - 32px)); + padding: 22px 26px 20px; + position: relative; +} +.orbit-account-picker__title { + margin: 0 0 6px; + padding-right: 1.5rem; + font-size: 17px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} +.orbit-account-picker__sub { + margin: 0 0 14px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} +.orbit-account-picker__list { + list-style: none; + padding: 0; + margin: 0 0 14px; + display: flex; + flex-direction: column; + gap: 4px; +} +.orbit-account-picker__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + font-size: 13px; + text-align: left; + background: color-mix(in srgb, var(--text-primary) 4%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +.orbit-account-picker__item svg { + color: var(--accent); + flex-shrink: 0; +} +.orbit-account-picker__item:hover, +.orbit-account-picker__item.is-active { + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.orbit-account-picker__user { + font-weight: 600; +} +.orbit-account-picker__name { + color: var(--text-muted); + font-weight: 400; +} +.orbit-account-picker__actions { + display: flex; + justify-content: flex-end; +} + +/* ── Start-session modal ────────────────────────────────────────── */ +.orbit-start-overlay { + align-items: center; + justify-content: center; +} + +.orbit-start-modal { + max-width: 480px; + width: min(480px, calc(100vw - 32px)); + padding: 18px 26px 16px; + position: relative; +} + +.orbit-start-modal__hero { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 0 0 10px; + margin-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.orbit-start-modal__hero-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--accent); + margin-bottom: 8px; + box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 50%, transparent); +} +.orbit-start-modal__hero-icon--live { + animation: orbit-hero-pulse 2.2s ease-in-out infinite; +} +@keyframes orbit-hero-pulse { + 0%, 100% { box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 45%, transparent); } + 50% { box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 75%, transparent); } +} + +.orbit-start-modal__title { + margin: 0 0 6px; + padding-right: 1.5rem; + font-size: 17px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.orbit-start-modal__hero .orbit-start-modal__title { + padding-right: 0; +} + +.orbit-start-modal__brand { + color: var(--accent); + font-weight: 700; + letter-spacing: -0.005em; +} + +.orbit-start-modal__sub { + margin: 0; + font-size: 13px; + color: var(--text-muted); + line-height: 1.55; +} + +.orbit-start-modal__sub strong { + color: var(--text-primary); + font-weight: 600; +} + +.orbit-start-modal__tip { + display: grid; + grid-template-columns: 18px 1fr; + align-items: start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 14px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary, var(--text-primary)); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: var(--radius-sm); +} +.orbit-start-modal__tip svg { + margin-top: 2px; + color: var(--accent); + flex-shrink: 0; +} +.orbit-start-modal__tip strong { + color: var(--text-primary); + font-weight: 600; +} +.orbit-start-modal__tip--warn { + background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent); + border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 45%, transparent); + color: var(--text-primary); +} +.orbit-start-modal__tip--warn svg { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-start-modal__tip--warn strong { + color: var(--ctp-yellow, #f9e2af); +} + +.orbit-start-modal__field { + margin-bottom: 14px; +} +.orbit-start-modal__field:last-of-type { + margin-bottom: 16px; +} + +.orbit-start-modal__label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.02em; + margin-bottom: 6px; +} +.orbit-start-modal__label strong { + font-size: 13px; + color: var(--accent); + font-weight: 700; + margin-left: 4px; +} + +.orbit-start-modal__helper { + margin-top: 6px; + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; +} + +.orbit-start-modal__toggle-row { + display: flex; + align-items: center; + gap: 14px; + cursor: pointer; +} +.orbit-start-modal__toggle-text { + flex: 1; + min-width: 0; +} +.orbit-start-modal__toggle-row .orbit-start-modal__label { + margin-bottom: 2px; +} +.orbit-start-modal__toggle-row .orbit-start-modal__helper { + margin-top: 0; +} + +.orbit-start-modal__note { + display: grid; + grid-template-columns: 18px 1fr; + align-items: start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary, var(--text-primary)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: var(--radius-sm); +} +.orbit-start-modal__note svg { + margin-top: 2px; + color: var(--accent); + flex-shrink: 0; +} +.orbit-start-modal__note--muted { + background: color-mix(in srgb, var(--text-primary) 4%, transparent); + border-color: color-mix(in srgb, var(--text-primary) 10%, transparent); +} +.orbit-start-modal__note--muted svg { + color: var(--text-muted); +} +.orbit-start-modal__note kbd { + display: inline-block; + padding: 1px 5px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10.5px; + font-weight: 600; + color: var(--text-primary); + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent); + border-radius: 3px; + line-height: 1.2; + vertical-align: baseline; +} +.orbit-start-modal__kbd-sep { + display: inline-block; + margin: 0 3px; + color: var(--text-muted); +} + +.orbit-start-modal__input-row { + display: flex; + align-items: stretch; + gap: 6px; +} + +.orbit-start-modal__input { + display: block; + width: 100%; + flex: 1; + min-width: 0; + padding: 9px 12px; + background: var(--ctp-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 14px; + box-sizing: border-box; + outline: none; +} +.orbit-start-modal__input:focus { + border-color: var(--accent); +} + +.orbit-start-modal__reshuffle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 260ms ease; +} +.orbit-start-modal__reshuffle:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--accent) 52%, transparent); +} +.orbit-start-modal__reshuffle:active { + transform: rotate(20deg); +} + +/* ── Host settings popover (from the gear in the Orbit bar) ──────── */ +.orbit-settings-pop { + min-width: 320px; + max-width: 380px; + padding: 14px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +.orbit-settings-pop__head { + padding: 2px 4px 12px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); + margin-bottom: 12px; +} + +.orbit-settings-pop__row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 8px; + margin-bottom: 4px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 120ms ease; +} +.orbit-settings-pop__row + .orbit-settings-pop__row { + margin-top: 0; +} +.orbit-settings-pop__row:hover { + background: color-mix(in srgb, var(--text-primary) 5%, transparent); +} + +.orbit-settings-pop__text { + flex: 1; + min-width: 0; +} + +.orbit-settings-pop__label { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 3px; +} + +.orbit-settings-pop__hint { + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + +.orbit-settings-pop .toggle-switch { + flex-shrink: 0; + margin-top: 2px; +} + +/* Row-variant for controls that want the control *under* the label rather + than beside it (preset pickers etc.). Non-interactive container: the + hover-background is suppressed here so the buttons keep focus. */ +.orbit-settings-pop__row--stacked { + flex-direction: column; + align-items: stretch; + gap: 8px; + cursor: default; +} +.orbit-settings-pop__row--stacked:hover { + background: transparent; +} + +.orbit-settings-pop__preset-group { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.orbit-settings-pop__preset { + flex: 1 1 0; + min-width: 44px; + padding: 6px 8px; + font-size: 11.5px; + font-weight: 600; + color: var(--text-secondary, var(--text-primary)); + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} +.orbit-settings-pop__preset:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text-primary); +} +.orbit-settings-pop__preset.is-active { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + color: var(--accent); +} +.orbit-settings-pop__preset:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.orbit-settings-pop__action { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 12px; + padding: 10px 12px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 180ms ease; +} +.orbit-settings-pop__action:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} +.orbit-settings-pop__action:active { transform: scale(0.98); } +.orbit-settings-pop__action svg { color: var(--accent); } + +/* ── Guest queue view (replaces QueuePanel body while role = guest) ── */ +.queue-panel--orbit-guest { padding: 0; } + +.orbit-guest-queue { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.orbit-queue-head { + padding: 14px 14px 10px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} +.orbit-queue-head__title-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + min-width: 0; +} +.orbit-queue-head__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.orbit-queue-head__presence { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.02em; + border-radius: 999px; + border-width: 1px; + border-style: solid; +} +.orbit-queue-head__presence--online { + color: var(--ctp-green, #a6e3a1); + background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 14%, transparent); + border-color: color-mix(in srgb, var(--ctp-green, #a6e3a1) 40%, transparent); +} +.orbit-queue-head__presence--online svg { + color: var(--ctp-green, #a6e3a1); +} +.orbit-queue-head__presence--away { + color: var(--ctp-yellow, #f9e2af); + background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent); + border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 40%, transparent); +} +.orbit-queue-head__presence--away svg { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-queue-head__meta { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-muted); + min-width: 0; +} +.orbit-queue-head__meta svg { color: var(--accent); flex-shrink: 0; } +.orbit-queue-head__names { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.orbit-guest-queue__current { + position: relative; + margin: 12px 10px 6px; + padding: 10px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + border-radius: var(--radius-md, 10px); + flex-shrink: 0; +} +.orbit-guest-queue__live-badge { + position: absolute; + top: 8px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ctp-crust, #11111b); + background: var(--accent); + border-radius: 999px; +} +.orbit-guest-queue__live-badge svg { animation: orbit-live-pulse 2s ease-in-out infinite; } +@keyframes orbit-live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.orbit-guest-queue__current-body { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.orbit-guest-queue__cover { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + object-fit: cover; + background: color-mix(in srgb, var(--text-primary) 6%, transparent); +} +.orbit-guest-queue__cover--lg { width: 54px; height: 54px; } +.orbit-guest-queue__cover--ph { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); +} +.orbit-guest-queue__info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 1px; +} +.orbit-guest-queue__track-title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__track-artist { + font-size: 11.5px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__note { + margin-top: 2px; + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + letter-spacing: 0.02em; +} + +.orbit-guest-queue__section-head { + padding: 14px 14px 6px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.orbit-guest-queue__count { + padding: 1px 6px; + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border-radius: 999px; + font-size: 10px; + color: var(--text-primary); +} + +.orbit-guest-queue__list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 2px 6px 8px; +} + +.orbit-guest-queue__item { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 8px; + border-radius: var(--radius-sm); + transition: background 120ms ease; +} +.orbit-guest-queue__item:hover { + background: color-mix(in srgb, var(--text-primary) 5%, transparent); +} +.orbit-guest-queue__submitter { + margin-top: 2px; + font-size: 10.5px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__submitter strong { + color: var(--accent); + font-weight: 600; +} + +/* Host manual-approval strip — rendered right below OrbitQueueHead when + autoApprove is off and guest suggestions are waiting. Prominent accent + colour so the host can't miss queued approvals. */ +.host-approval { + margin: 0 10px 10px; + padding: 10px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: var(--radius-md, 10px); +} +.host-approval__head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.host-approval__head svg { color: var(--accent); flex-shrink: 0; } +.host-approval__count { + margin-left: auto; + padding: 1px 7px; + background: var(--accent); + color: var(--bg-primary, #0b0b13); + border-radius: 999px; + font-size: 10px; + letter-spacing: 0; + text-transform: none; +} +.host-approval__list { + display: flex; + flex-direction: column; + gap: 4px; +} +.host-approval__item { + display: flex; + align-items: center; + gap: 9px; + padding: 6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text-primary) 4%, transparent); +} +.host-approval__cover { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} +.host-approval__cover--ph { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); +} +.host-approval__info { + flex: 1; + min-width: 0; + overflow: hidden; +} +.host-approval__title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.host-approval__artist { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.host-approval__submitter { + margin-top: 2px; + font-size: 10.5px; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.host-approval__actions { + display: inline-flex; + gap: 4px; + flex-shrink: 0; +} +.host-approval__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent); + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} +.host-approval__btn--approve:hover { + background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 28%, transparent); + border-color: var(--ctp-green, #a6e3a1); + color: var(--ctp-green, #a6e3a1); +} +.host-approval__btn--decline:hover { + background: color-mix(in srgb, var(--danger, #f38ba8) 28%, transparent); + border-color: var(--danger, #f38ba8); + color: var(--danger, #f38ba8); +} + +/* Pending suggestions — tracks the guest has submitted but the host + hasn't merged yet. Looks like the regular queue section, with a subtle + yellow accent and a clock icon in the heading. */ +.orbit-guest-queue__pending { + padding: 0 6px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 6px; +} +.orbit-guest-queue__section-head--pending { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-guest-queue__section-head--pending svg { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-guest-queue__item--pending { + opacity: 0.85; +} +.orbit-guest-queue__pending-hint { + margin-top: 2px; + font-size: 10.5px; + color: var(--text-muted); + font-style: italic; +} + +.orbit-guest-queue__empty { + padding: 18px 16px; + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.5; + text-align: center; +} + +.orbit-guest-queue__more { + padding: 8px 14px; + font-size: 11px; + color: var(--text-muted); + text-align: center; + font-style: italic; +} + +.orbit-guest-queue__footer { + padding: 8px 14px 10px; + font-size: 10.5px; + color: var(--text-muted); + text-align: center; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.orbit-start-modal__range { + display: block; + width: 100%; + margin: 14px 0 10px; + accent-color: var(--accent); +} + +.orbit-start-modal__error { + margin-bottom: 12px; + padding: 8px 10px; + font-size: 12px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--ctp-red, #f38ba8) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent); + color: var(--ctp-red, #f38ba8); +} + +.orbit-start-modal__actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 10px; +} + +.orbit-start-modal__link { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 18px; + padding: 10px 12px; + background: var(--ctp-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-mono, ui-monospace, 'Courier New', monospace); + font-size: 11.5px; + color: var(--accent); + word-break: break-all; +} +.orbit-start-modal__link code { + flex: 1; + min-width: 0; + background: none; + padding: 0; + color: inherit; +} +.orbit-start-modal__copy { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease; +} +.orbit-start-modal__copy:hover { + background: color-mix(in srgb, var(--accent) 26%, transparent); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 1b55396a..d1e4c62b 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -2160,6 +2160,26 @@ html[data-platform="windows"] .player-bar.floating { opacity: 0.8; } +.queue-item-attribution, +.queue-current-attribution { + font-size: 10px; + color: var(--text-muted); + opacity: 0.75; + margin-top: 1px; + font-style: italic; +} + +.queue-item:hover .queue-item-attribution, +.queue-item.context-active .queue-item-attribution { + color: var(--text-secondary); + opacity: 0.9; +} + +.queue-item.active .queue-item-attribution { + color: inherit; + opacity: 0.7; +} + /* ═══════════════════════════════════════════════════════════════════════════ MOBILE LAYOUT (< 800px) Controller: data-mobile attribute set by useIsMobile hook diff --git a/src/styles/tracks.css b/src/styles/tracks.css new file mode 100644 index 00000000..435d0a65 --- /dev/null +++ b/src/styles/tracks.css @@ -0,0 +1,525 @@ +/* ───────────────────────────────────────────────────────────────── + Tracks Page — Hub view (rails on top + virtualized browse below) + ──────────────────────────────────────────────────────────────── */ + +.tracks-page { + display: flex; + flex-direction: column; + gap: var(--space-6, 1.5rem); + padding-bottom: var(--space-8, 2rem); +} + +.tracks-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); +} + +.tracks-subtitle { + margin: 4px 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +/* ─── Hero ────────────────────────────────────────────────────── */ + +.tracks-hero { + display: flex; + gap: var(--space-5, 1.25rem); + padding: var(--space-4); + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + overflow: hidden; + position: relative; +} + +.tracks-hero-cover { + flex: 0 0 160px; + width: 160px; + height: 160px; + border-radius: var(--radius-md, 8px); + overflow: hidden; + background: var(--bg-hover); + box-shadow: var(--shadow-md); +} + +.tracks-hero-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tracks-hero-cover-placeholder { + width: 100%; + height: 100%; +} + +.tracks-hero-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} + +.tracks-hero-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent); +} + +.tracks-hero-title { + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tracks-hero-meta { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tracks-hero-meta-dot { + margin: 0 8px; + opacity: 0.5; +} + +.tracks-hero-actions { + margin-top: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.tracks-hero-actions .btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* ─── Spinner helper ──────────────────────────────────────────── */ + +@keyframes tracks-spin { + to { transform: rotate(360deg); } +} + +.is-spinning { + animation: tracks-spin 1s linear infinite; + transform-origin: center; +} + +/* ─── Song Rail (mirrors album-row pattern) ───────────────────── */ + +.song-row-section { + display: flex; + flex-direction: column; + gap: var(--space-3); + position: relative; +} + +.song-row-header { + display: flex; + align-items: flex-end; + justify-content: space-between; +} + +.song-row-nav { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.song-row-nav .nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--bg-card); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + transition: all var(--transition-fast); + cursor: pointer; +} + +.song-row-nav .nav-btn:hover:not(.disabled):not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--border); +} + +.song-row-nav .nav-btn.disabled, +.song-row-nav .nav-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.song-grid-wrapper { + position: relative; +} + +.song-grid { + display: flex; + gap: var(--space-3); + overflow-x: auto; + padding-bottom: var(--space-3); + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.song-grid::-webkit-scrollbar { + display: none; +} + +.song-row-empty { + font-size: 13px; + color: var(--text-muted); + padding: var(--space-3) 0; +} + +/* ─── Song Card ───────────────────────────────────────────────── */ + +.song-card { + position: relative; + flex: 0 0 140px; + width: 140px; + cursor: pointer; + background: var(--bg-card); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--border-subtle); + transition: box-shadow var(--transition-base); +} + +.song-card:hover { + box-shadow: inset 0 0 0 1px var(--border), var(--shadow-md); +} + +.song-card-cover { + position: relative; + aspect-ratio: 1; + overflow: hidden; + background: var(--bg-hover); +} + +.song-card-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.song-card-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.song-card-play-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + opacity: 0; + transition: opacity var(--transition-base); +} + +.song-card:hover .song-card-play-overlay { + opacity: 1; +} + +.song-card-action-btn { + background: var(--accent); + color: var(--ctp-crust); + border: none; + width: 32px; + height: 32px; + border-radius: var(--radius-full, 999px); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform var(--transition-fast); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +.song-card-action-btn:hover { + transform: scale(1.08); +} + +.song-card-info { + padding: var(--space-2) var(--space-3) var(--space-3); +} + +.song-card-title { + font-weight: 600; + font-size: 12px; + color: var(--text-primary); + margin: 0 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.song-card-artist { + margin: 0; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Virtual Song List ───────────────────────────────────────── */ + +.virtual-song-list-section { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-2); +} + +.virtual-song-list-title { + margin: 0; +} + +.virtual-song-list-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + justify-content: space-between; +} + +.virtual-song-list-search { + position: relative; + flex: 1; + max-width: 480px; + display: flex; + align-items: center; +} + +.virtual-song-list-search-icon { + position: absolute; + left: 10px; + color: var(--text-muted); + pointer-events: none; +} + +.virtual-song-list-search-input { + width: 100%; + padding-left: 34px; + padding-right: 32px; +} + +.virtual-song-list-search-clear { + position: absolute; + right: 6px; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.virtual-song-list-search-clear:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.virtual-song-list-meta { + font-size: 12px; + color: var(--text-muted); + flex-shrink: 0; +} + + +.virtual-song-list-empty { + padding: var(--space-6, 1.5rem) var(--space-4); + text-align: center; + font-size: 13px; + color: var(--text-muted); + background: var(--bg-card); + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-lg); +} + +.virtual-song-list-scroll { + max-height: 70vh; + min-height: 320px; + overflow-y: auto; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--bg-card); +} + +.virtual-song-list-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: var(--space-3); + font-size: 12px; + color: var(--text-muted); +} + +/* ─ Shared SongRow (used by Tracks Hub, SearchResults, AdvancedSearch) ─ */ + +.song-list-row { + display: grid; + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 110px 56px; + gap: var(--space-3); + align-items: center; + height: 52px; + padding: 0 var(--space-3); + font-size: 13px; + cursor: default; + transition: background var(--transition-fast); + border-bottom: 1px solid var(--border-subtle); +} + +.song-list-row:hover { + background: var(--bg-hover); +} + +.song-list-row.is-current { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.song-list-row.is-current .song-list-row-title { + color: var(--accent); +} + +.song-list-row--header { + height: 36px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + cursor: default; + background: transparent; + border-bottom: 1px solid var(--border-subtle); +} + +.song-list-row--header:hover { + background: transparent; +} + +.song-list-row-cell { + min-width: 0; +} + +.song-list-row-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.song-list-row-title { + font-weight: 500; + color: var(--text-primary); +} + +.song-list-row-genre { + color: var(--text-muted); + font-size: 12px; +} + +.song-list-row-duration { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + font-size: 12px; +} + +.song-list-row-btn { + background: transparent; + border: none; + color: var(--text-muted); + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.song-list-row-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.song-list-row-btn--play { + color: var(--accent); +} + +.song-list-row-btn--play:hover { + background: var(--accent); + color: var(--ctp-crust, #11111b); +} + +.song-list-row.is-current .song-list-row-btn--play { + color: var(--accent); +} + +/* ─── Responsive ──────────────────────────────────────────────── */ + +@media (max-width: 700px) { + .tracks-hero { + flex-direction: column; + align-items: center; + text-align: center; + } + + .tracks-hero-cover { + flex: 0 0 140px; + width: 140px; + height: 140px; + } + + .tracks-hero-actions { + justify-content: center; + } + + .song-list-row { + grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px; + } + + .song-list-row-cell:nth-child(4), /* album */ + .song-list-row-cell:nth-child(5) { /* genre */ + display: none; + } +} diff --git a/src/utils/applySharePaste.ts b/src/utils/applySharePaste.ts index d2aa1ef1..3d097aad 100644 --- a/src/utils/applySharePaste.ts +++ b/src/utils/applySharePaste.ts @@ -3,7 +3,7 @@ import type { TFunction } from 'i18next'; import { getAlbum, getArtist, getSong, type SubsonicSong } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { songToTrack, usePlayerStore } from '../store/playerStore'; -import { findServerIdForShareUrl, type SharePayloadV1 } from './shareLink'; +import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink'; import { showToast } from './toast'; const RESOLVE_QUEUE_CHUNK = 12; @@ -14,7 +14,7 @@ const RESOLVE_QUEUE_CHUNK = 12; * the payload was already decoded successfully. */ export async function applySharePastePayload( - payload: SharePayloadV1, + payload: EntitySharePayloadV1, navigate: NavigateFunction, t: TFunction, ): Promise { diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts new file mode 100644 index 00000000..29b0d060 --- /dev/null +++ b/src/utils/orbit.ts @@ -0,0 +1,995 @@ +import { + createPlaylist, + updatePlaylist, + updatePlaylistMeta, + deletePlaylist, + getPlaylist, + getPlaylists, + getSong, +} from '../api/subsonic'; +import { useAuthStore } from '../store/authStore'; +import { useOrbitStore } from '../store/orbitStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { encodeSharePayload, decodeOrbitSharePayloadFromText } from './shareLink'; +import { + makeInitialOrbitState, + orbitOutboxPlaylistName, + orbitSessionPlaylistName, + parseOrbitState, + ORBIT_DEFAULT_MAX_USERS, + ORBIT_PLAYLIST_PREFIX, + ORBIT_STATE_MAX_BYTES, + type OrbitOutboxMeta, + type OrbitParticipant, + type OrbitQueueItem, + type OrbitState, +} from '../api/orbit'; + +/** + * Orbit — host-side lifecycle primitives. + * + * Phase 2 scope: creating / ending a session, serialising state into the + * canonical playlist comment, writing a heartbeat into the host's own + * outbox. No guest-side logic here. + * + * All functions talk to Navidrome through the existing Subsonic wrappers; + * no new transport work. + */ + +// ── ID generation ─────────────────────────────────────────────────────── + +/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */ +export function generateSessionId(): string { + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +// ── Serialisation ─────────────────────────────────────────────────────── + +/** + * Serialise the state blob for writing into a playlist comment. Emits a + * plain JSON string. Throws when the output exceeds `ORBIT_STATE_MAX_BYTES` + * — callers should trim optional fields (oldest queue entries / kicked + * usernames) and retry, rather than write something truncated. + */ +export function serialiseOrbitState(state: OrbitState): string { + const json = JSON.stringify(state); + // Encode-length check — emoji-heavy session names could inflate UTF-8 bytes + // beyond the string's .length count. + const byteLen = new TextEncoder().encode(json).length; + if (byteLen > ORBIT_STATE_MAX_BYTES) { + throw new OrbitStateTooLarge(byteLen); + } + return json; +} + +export class OrbitStateTooLarge extends Error { + constructor(public readonly bytes: number) { + super(`Orbit state blob (${bytes} bytes) exceeds ${ORBIT_STATE_MAX_BYTES} byte budget`); + this.name = 'OrbitStateTooLarge'; + } +} + +function serialiseOutboxMeta(meta: OrbitOutboxMeta): string { + return JSON.stringify(meta); +} + +// ── Remote reads ──────────────────────────────────────────────────────── + +/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */ +export async function readOrbitState(sessionPlaylistId: string): Promise { + try { + const { playlist } = await getPlaylist(sessionPlaylistId); + if (!playlist.comment) return null; + let raw: unknown; + try { raw = JSON.parse(playlist.comment); } catch { return null; } + return parseOrbitState(raw); + } catch { return null; } +} + +// ── Remote writes ─────────────────────────────────────────────────────── + +/** + * Write the state blob into the session playlist's comment. + * + * NOTE (design doc "known rough edges"): `updatePlaylist.view` with name + + * comment MUST preserve the track list. Confirmed to work on Navidrome via + * observation in PR #256 (playlist-editor); if a future Navidrome release + * ever changes that, we need to switch to `updatePlaylist` with the full + * track list echoed back. + */ +export async function writeOrbitState( + sessionPlaylistId: string, + state: OrbitState, +): Promise { + const comment = serialiseOrbitState(state); + const name = orbitSessionPlaylistName(state.sid); + await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true); +} + +/** + * Write a heartbeat into the given outbox playlist's comment. Host keeps one + * for symmetry + to feed its own presence into the participants pipeline + * (used from Phase 4 onwards when guests look for host liveness). + */ +export async function writeOrbitHeartbeat( + outboxPlaylistId: string, + outboxName: string, +): Promise { + const meta: OrbitOutboxMeta = { ts: Date.now() }; + await updatePlaylistMeta(outboxPlaylistId, outboxName, serialiseOutboxMeta(meta), /* public */ true); +} + +// ── Host lifecycle ────────────────────────────────────────────────────── + +export interface StartOrbitArgs { + /** Human-readable name the host chose. */ + name: string; + /** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */ + maxUsers?: number; + /** + * Pre-generated session id. Lets the caller (e.g. the start modal) show a + * stable share-link *before* the session is actually created. Falls back + * to a fresh id when omitted. + */ + sid?: string; +} + +/** + * Host: create a new session. + * + * Creates both the canonical session playlist and the host's own outbox, + * seeds the state blob + heartbeat, binds the store, sets phase to `active`. + * + * Throws if the Navidrome server isn't available or lacks a logged-in user. + * On throw the store is left in the pre-call state — nothing partially bound. + */ +export async function startOrbitSession(args: StartOrbitArgs): Promise { + const server = useAuthStore.getState().getActiveServer(); + const username = server?.username; + if (!username) throw new Error('No active Navidrome server / user'); + + const store = useOrbitStore.getState(); + if (store.phase !== 'idle') { + throw new Error(`Cannot start while phase is ${store.phase}`); + } + + store.setPhase('starting'); + + let sessionPlaylistId: string | null = null; + let outboxPlaylistId: string | null = null; + try { + const sid = args.sid ?? generateSessionId(); + const sessionName = orbitSessionPlaylistName(sid); + const outboxName = orbitOutboxPlaylistName(sid, username); + + // Create both playlists. Navidrome's createPlaylist returns the created + // object with its new id. + const sessionPlaylist = await createPlaylist(sessionName); + sessionPlaylistId = sessionPlaylist.id; + + const outboxPlaylist = await createPlaylist(outboxName); + outboxPlaylistId = outboxPlaylist.id; + + // Seed state blob + heartbeat. We use updatePlaylistMeta instead of + // separate create-with-comment because Subsonic's createPlaylist doesn't + // take a comment argument. + const state = makeInitialOrbitState({ + sid, + host: username, + name: args.name, + maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS, + }); + await writeOrbitState(sessionPlaylistId, state); + await writeOrbitHeartbeat(outboxPlaylistId, outboxName); + + // Bind local store — session is now live. + useOrbitStore.setState({ + role: 'host', + sessionId: sid, + sessionPlaylistId, + outboxPlaylistId, + phase: 'active', + state, + errorMessage: null, + joinedAt: Date.now(), + }); + + return state; + } catch (err) { + // Best-effort cleanup of anything we managed to create before the failure. + if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } } + if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* ignore */ } } + useOrbitStore.getState().setPhase('idle'); + throw err; + } +} + +/** + * Host: end the session cleanly. + * + * Writes `ended: true` first so any poll-in-progress from a guest sees the + * signal, then deletes both playlists and resets the local store. Each step + * is best-effort; if something's already gone server-side we still zero out + * local state so the UI returns to idle. + */ +export async function endOrbitSession(): Promise { + const { role, state, sessionPlaylistId, outboxPlaylistId } = useOrbitStore.getState(); + if (role !== 'host') return; + + // 1) Flip `ended` so guests notice on their next poll even if deletion fails. + if (sessionPlaylistId && state) { + try { + await writeOrbitState(sessionPlaylistId, { ...state, ended: true }); + } catch { /* best-effort */ } + } + + // 2) Delete both playlists. Order: outbox first — if session delete fails, + // a stale session playlist with ended=true is fine; a stale outbox without + // a session is noise. + if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } } + if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } } + + // 3) Local teardown. + useOrbitStore.getState().reset(); +} + +// ── Store helpers used by the tick hook ──────────────────────────────── + +/** Merge a patch into the store's state blob, keeping nullability. */ +export function patchOrbitState(patch: Partial): OrbitState | null { + const current = useOrbitStore.getState().state; + if (!current) return null; + const next: OrbitState = { ...current, ...patch }; + useOrbitStore.getState().setState(next); + return next; +} + +/** + * Host-only: update the session settings and immediately push to Navidrome + * so guests see the change on their next poll. No-op unless the caller is + * the current host with an active session. + */ +/** + * Host-only: force an immediate shuffle of the upcoming play queue, bump + * `lastShuffle` so the automatic 15-min timer resets, and push the new + * state to Navidrome. Ignores the `autoShuffle` setting — this is an + * explicit user action. + */ +export async function triggerOrbitShuffleNow(): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return; + + // 1) Shuffle the host's real play queue (upcoming only). + usePlayerStore.getState().shuffleUpcomingQueue(); + + // 2) Shuffle the OrbitState.queue (guest-facing suggestion history) + + // bump lastShuffle so the auto-shuffle timer restarts. + const now = Date.now(); + const shuffled = store.state.queue.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now }; + store.setState(next); + try { await writeOrbitState(store.sessionPlaylistId, next); } + catch { /* best-effort; next host-tick will push */ } +} + +export async function updateOrbitSettings(patch: Partial): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return; + const mergedSettings: import('../api/orbit').OrbitSettings = { + ...(store.state.settings ?? { autoApprove: true, autoShuffle: true }), + ...patch, + }; + const next: OrbitState = { ...store.state, settings: mergedSettings }; + store.setState(next); + try { await writeOrbitState(store.sessionPlaylistId, next); } + catch { /* best-effort; next host-tick will push the current state anyway */ } +} + +// ── Share link ────────────────────────────────────────────────────────── + +export interface OrbitShareLink { + /** Base URL of the Navidrome server (decoded). */ + serverBase: string; + /** Session id (8 hex chars). */ + sid: string; +} + +/** + * Parse an orbit invite from pasted text. Accepts the magic-string format + * `psysonic2-` (same prefix family as library shares and + * server invites). The caller decides what to do on null (show toast, etc.). + */ +export function parseOrbitShareLink(text: string): OrbitShareLink | null { + if (!text) return null; + const payload = decodeOrbitSharePayloadFromText(text); + if (!payload) return null; + try { new URL(payload.srv); } catch { return null; } + return { serverBase: payload.srv, sid: payload.sid }; +} + +/** Build an orbit invite magic string for a live session. */ +export function buildOrbitShareLink(serverBase: string, sid: string): string { + return encodeSharePayload({ srv: serverBase, k: 'orbit', sid }); +} + +// ── Playlist lookup ───────────────────────────────────────────────────── + +/** + * Find the Navidrome playlist id of a session given its session id. + * Scans the user's visible playlist list — Navidrome exposes public + * playlists from other users, so a guest can find the host's session. + */ +export async function findSessionPlaylistId(sid: string): Promise { + const target = orbitSessionPlaylistName(sid); + try { + const all = await getPlaylists(true); + const hit = all.find(p => p.name === target); + return hit?.id ?? null; + } catch { return null; } +} + +// ── Guest lifecycle ───────────────────────────────────────────────────── + +export class OrbitJoinError extends Error { + constructor( + public readonly reason: 'not-found' | 'ended' | 'full' | 'kicked' | 'no-user' | 'server-error', + message: string, + ) { + super(message); + this.name = 'OrbitJoinError'; + } +} + +/** + * Guest: join an existing session by id. + * + * Assumes the user is already authenticated against the correct Navidrome + * server — the caller's UI layer handles the magic-sharing flow when the + * encoded server in the share link doesn't match the active one. + * + * Side effects on success: + * - creates this user's outbox playlist and writes a first heartbeat + * - binds `useOrbitStore` to the session (role = guest, phase = active) + * - populates the store's `state` mirror with the last-known blob + * + * Throws `OrbitJoinError` on any gate failure; caller shows an error + * modal and does nothing else. + */ +export async function joinOrbitSession(sid: string): Promise { + const server = useAuthStore.getState().getActiveServer(); + const username = server?.username; + if (!username) throw new OrbitJoinError('no-user', 'No active Navidrome server / user'); + + const store = useOrbitStore.getState(); + if (store.phase !== 'idle') { + throw new OrbitJoinError('server-error', `Cannot join while phase is ${store.phase}`); + } + + store.setPhase('joining'); + + let outboxPlaylistId: string | null = null; + try { + // 1) Locate the session playlist and read its state blob. + const sessionPlaylistId = await findSessionPlaylistId(sid); + if (!sessionPlaylistId) throw new OrbitJoinError('not-found', `Session ${sid} not found on server`); + + const state = await readOrbitState(sessionPlaylistId); + if (!state) throw new OrbitJoinError('not-found', `Session ${sid} has no valid state`); + if (state.ended) throw new OrbitJoinError('ended', `Session ${sid} has ended`); + + // 2) Gate: not kicked, not full. Note: host isn't in `participants` itself, + // so `maxUsers` counts guests only. + if (state.kicked.includes(username)) { + throw new OrbitJoinError('kicked', `You were removed from session ${sid}`); + } + const alreadyInside = state.participants.some(p => p.user === username); + if (!alreadyInside && state.participants.length >= state.maxUsers) { + throw new OrbitJoinError('full', `Session ${sid} is full (${state.maxUsers}/${state.maxUsers})`); + } + + // 3) Create our outbox + first heartbeat. + const outboxName = orbitOutboxPlaylistName(sid, username); + // Guard against a stale outbox from a previous abandoned join attempt — + // if one exists under the same name, reuse its id instead of creating + // a duplicate (Navidrome allows duplicate names but it'd leak). + const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName); + if (existing) { + outboxPlaylistId = existing.id; + } else { + const outbox = await createPlaylist(outboxName); + outboxPlaylistId = outbox.id; + } + await writeOrbitHeartbeat(outboxPlaylistId, outboxName); + + // 4) Bind the local store. The host's next poll will register us in + // `participants` — we don't self-mutate the canonical state. + useOrbitStore.setState({ + role: 'guest', + sessionId: sid, + sessionPlaylistId, + outboxPlaylistId, + phase: 'active', + state, + errorMessage: null, + joinedAt: Date.now(), + }); + + return state; + } catch (err) { + // Best-effort cleanup. + if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } } + useOrbitStore.getState().setPhase('idle'); + throw err; + } +} + +/** + * Guest: leave a session voluntarily. + * + * Deletes our outbox (so the host stops counting us after its next sweep) + * and resets the local store. Best-effort on each step. Does NOT touch the + * canonical session playlist — that's the host's property. + */ +export async function leaveOrbitSession(): Promise { + const { role, outboxPlaylistId } = useOrbitStore.getState(); + if (role !== 'guest') return; + + if (outboxPlaylistId) { + try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } + } + + useOrbitStore.getState().reset(); +} + +// ── Track pipeline ────────────────────────────────────────────────────── + +/** + * Guest: suggest a track to the session. + * + * Appends the track to our own outbox playlist. The host's next sweep will + * consume it and publish the authoritative queue update in the state blob. + * No state mutation here — the guest never touches canonical state. + */ +/** Why a guest's suggestion would be blocked, in priority order. `null` means + * the suggestion can proceed. */ +export type OrbitSuggestGateReason = 'not-guest' | 'muted' | null; + +/** + * Evaluate whether the local guest is allowed to send a new suggestion right + * now — used by both the UI (to disable buttons / show toasts) and + * {@link suggestOrbitTrack} as a defensive check. + */ +export function evaluateOrbitSuggestGate(): { allowed: boolean; reason: OrbitSuggestGateReason } { + const { role, state } = useOrbitStore.getState(); + if (role !== 'guest' || !state) return { allowed: false, reason: 'not-guest' }; + const username = useAuthStore.getState().getActiveServer()?.username ?? ''; + if (state.suggestionBlocked?.includes(username)) { + return { allowed: false, reason: 'muted' }; + } + return { allowed: true, reason: null }; +} + +export class OrbitSuggestBlockedError extends Error { + constructor(public readonly reason: Exclude) { + super(`Suggestion blocked: ${reason}`); + this.name = 'OrbitSuggestBlockedError'; + } +} + +export async function suggestOrbitTrack(trackId: string): Promise { + const gate = evaluateOrbitSuggestGate(); + if (!gate.allowed && gate.reason && gate.reason !== 'not-guest') { + throw new OrbitSuggestBlockedError(gate.reason); + } + const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState(); + if (role !== 'guest') throw new Error('Not joined to a session as a guest'); + if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound'); + + // Read current outbox contents and append — createPlaylist.view with + // playlistId replaces songs wholesale, so we need to carry the existing + // list along. + const { songs } = await getPlaylist(outboxPlaylistId); + const nextIds = [...songs.map(s => s.id), trackId]; + await updatePlaylist(outboxPlaylistId, nextIds, songs.length); + + // Record the suggestion locally so the UI can surface it as "waiting on + // host" until the host's next sweep merges it into the shared queue. + // Drained by the guest tick's reconcilePendingSuggestions call. + useOrbitStore.getState().addPendingSuggestion(trackId); +} + +/** + * Stable per-suggestion key across reshuffles — `addedBy`, `addedAt` and + * `trackId` are all immutable once the host sweep has written them. + * Shared between the host tick and the manual-approval UI. + */ +export const suggestionKey = (q: OrbitQueueItem): string => + `${q.addedBy}:${q.addedAt}:${q.trackId}`; + +/** + * Host: accept a guest suggestion and route it into the live play queue. + * No-op outside host role. Uses the shared `mergedSuggestionKeys` store + * slot so the tick doesn't re-process the same item. + */ +export async function approveOrbitSuggestion(q: OrbitQueueItem): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state) return; + try { + const song = await getSong(q.trackId); + if (!song) return; + const track = songToTrack(song); + usePlayerStore.getState().enqueue([track]); + store.addMergedSuggestion(suggestionKey(q)); + } catch { /* silent */ } +} + +/** + * Host: reject a guest suggestion. It stays in `OrbitState.queue` as + * history but is filtered out of the approval UI and the merge tick. + */ +export function declineOrbitSuggestion(q: OrbitQueueItem): void { + const store = useOrbitStore.getState(); + if (store.role !== 'host') return; + store.addDeclinedSuggestion(suggestionKey(q)); +} + +/** + * Host: add a track to the active Orbit session directly, skipping the + * outbox/approval loop guests go through. The track lands in the host's + * own play queue immediately and is attributed to the host in the + * session's suggestion history. Host-authored queue items are filtered + * out of the tick-merge pipeline so the host-tick doesn't re-insert the + * same track once it notices the new entry in `OrbitState.queue`. + */ +/** + * App-start sweep: delete our own __psyorbit_* playlists that no longer + * belong to a live session. "Live" means either this device's current + * session (never touch) or one whose heartbeat is less than + * `ORBIT_ORPHAN_TTL_MS` old (could be a session on another device of + * ours). Anything older — including unparseable / comment-less entries — + * is a leftover from a crash / force-close / network blip and gets + * removed so it doesn't clutter the Navidrome playlist view. + * + * Runs best-effort; individual failures are swallowed. Returns the count + * of playlists actually deleted, for logging. + */ +export async function cleanupOrphanedOrbitPlaylists(): Promise { + const username = useAuthStore.getState().getActiveServer()?.username; + if (!username) return 0; + + const all = await getPlaylists(true).catch(() => [] as Awaited>); + const now = Date.now(); + const TTL = ORBIT_ORPHAN_TTL_MS; + const currentSid = useOrbitStore.getState().sessionId; + + const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`); + let deleted = 0; + + for (const p of all) { + if (!p.name.startsWith(ORBIT_PLAYLIST_PREFIX)) continue; + // Only touch our own — Navidrome rejects deletes on foreign playlists anyway. + if (p.owner && p.owner !== username) continue; + + const match = p.name.match(nameRe); + // Not one we recognise — assume corrupt, prune. + if (!match) { + try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ } + continue; + } + const sid = match[1]; + const isOutbox = !!match[2]; + if (sid === currentSid) continue; + + let timestamp = 0; + let ended = false; + if (p.comment) { + try { + const parsed = JSON.parse(p.comment); + if (isOutbox) { + if (parsed && typeof parsed.ts === 'number') timestamp = parsed.ts; + } else { + const state = parseOrbitState(parsed); + if (state) { + timestamp = state.positionAt ?? 0; + ended = state.ended === true; + } + } + } catch { /* unparseable → treat as dead */ } + } + + // Fall back to Navidrome's `changed` timestamp when there's no + // orbit-authored heartbeat in the comment — saves us from deleting a + // playlist that was just created seconds ago. + if (timestamp === 0 && p.changed) { + const parsed = Date.parse(p.changed); + if (!isNaN(parsed)) timestamp = parsed; + } + + const stale = timestamp === 0 || (now - timestamp > TTL); + if (ended || stale) { + try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ } + } + } + return deleted; +} + +export async function hostEnqueueToOrbit(trackId: string): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) { + throw new Error('Not hosting an active Orbit session'); + } + + const song = await getSong(trackId); + if (!song) throw new Error('Track not found'); + const track = songToTrack(song); + + usePlayerStore.getState().enqueue([track]); + + const item: OrbitQueueItem = { trackId, addedBy: store.state.host, addedAt: Date.now() }; + const next: OrbitState = { ...store.state, queue: [...store.state.queue, item] }; + store.setState(next); + try { await writeOrbitState(store.sessionPlaylistId, next); } + catch { /* best-effort; next host-tick will push the merged state anyway */ } +} + +// ── Host-side outbox sweep ────────────────────────────────────────────── + +interface OutboxSnapshot { + user: string; + outboxPlaylistId: string; + /** Track IDs currently sitting in the outbox — these are the new suggestions. */ + trackIds: string[]; + /** Last heartbeat timestamp parsed from the outbox comment, or 0 if missing/broken. */ + lastHeartbeat: number; +} + +/** Extract `` from a filename matching `__psyorbit__from___`. */ +function parseOutboxPlaylistName(name: string, sid: string): string | null { + const prefix = `${ORBIT_PLAYLIST_PREFIX}${sid}_from_`; + if (!name.startsWith(prefix) || !name.endsWith('__')) return null; + const user = name.slice(prefix.length, name.length - 2); + return user.length > 0 ? user : null; +} + +/** + * Host: list all guest outbox playlists for the current session. + * Skips the host's own outbox — that's heartbeat-only, not a suggestion channel. + */ +async function listGuestOutboxes(sid: string, hostUsername: string): Promise> { + const all = await getPlaylists(true).catch(() => []); + const result: Array<{ id: string; name: string; user: string }> = []; + for (const p of all) { + const user = parseOutboxPlaylistName(p.name, sid); + if (!user || user === hostUsername) continue; + result.push({ id: p.id, name: p.name, user }); + } + return result; +} + +/** + * Host: read one outbox's contents (suggested tracks + heartbeat ts). + */ +async function readOutbox(playlistId: string): Promise<{ trackIds: string[]; lastHeartbeat: number }> { + try { + const { playlist, songs } = await getPlaylist(playlistId); + let ts = 0; + if (playlist.comment) { + try { + const meta = JSON.parse(playlist.comment) as Partial; + if (typeof meta.ts === 'number') ts = meta.ts; + } catch { /* malformed — treat as no heartbeat */ } + } + return { trackIds: songs.map(s => s.id), lastHeartbeat: ts }; + } catch { + return { trackIds: [], lastHeartbeat: 0 }; + } +} + +/** + * Host: sweep every guest outbox once. + * + * - Collects suggested track IDs from each outbox (returns them so the + * caller can wire them into the state queue with `addedBy` = user). + * - Captures the latest heartbeat ts per user for the participants list. + * - Clears the outbox track list after reading — a single-pass consume + * semantic: once the host has seen a track, the guest doesn't need to + * show it as "pending" any longer. The outbox's heartbeat comment is + * left untouched because the guest's own heartbeat hook keeps refreshing it. + * + * Returns a list of snapshots, one per live guest outbox. Errors on + * individual outboxes are swallowed — best-effort. + */ +export async function sweepGuestOutboxes(sid: string, hostUsername: string): Promise { + const outboxes = await listGuestOutboxes(sid, hostUsername); + const snaps: OutboxSnapshot[] = []; + for (const ob of outboxes) { + const { trackIds, lastHeartbeat } = await readOutbox(ob.id); + snaps.push({ user: ob.user, outboxPlaylistId: ob.id, trackIds, lastHeartbeat }); + if (trackIds.length > 0) { + // Clear the outbox tracks. Leaves the heartbeat comment untouched. + try { await updatePlaylist(ob.id, [], trackIds.length); } catch { /* best-effort */ } + } + } + return snaps; +} + +// ── State-blob construction from sweep results ───────────────────────── + +/** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */ +export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000; + +/** + * Grace window for the app-start orphan sweep. A session on the user's + * other device or a browser that briefly restarted must NOT be deleted + * by this sweep. 5 min matches the guest-side host-timeout threshold: + * if a session is silent for that long, it's fair to treat it as dead; + * anything shorter is a real restart and must survive. + */ +export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000; + +/** + * Legacy / fallback shuffle cadence. New sessions store their own interval + * in `OrbitState.settings.shuffleIntervalMin`; `effectiveShuffleIntervalMs` + * resolves that against this constant for sessions created before the + * field existed. + */ +export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; + +/** + * Resolve the active auto-shuffle cadence in ms. Reads the host's configured + * preset from `state.settings.shuffleIntervalMin`; older sessions that lack + * the field fall back to 15 min so their tick cadence is unchanged. + */ +export function effectiveShuffleIntervalMs(state: Pick): number { + const min = state.settings?.shuffleIntervalMin; + return typeof min === 'number' ? min * 60_000 : ORBIT_SHUFFLE_INTERVAL_MS; +} + +/** + * How long a soft-`removed` marker stays in the state blob. Long enough for + * the affected guest's 2.5 s read tick to surface the modal even after a + * one-tick miss; short enough that the marker doesn't bloat state if the + * guest never reconnects. + */ +export const ORBIT_REMOVED_TTL_MS = 60_000; + +/** + * Host helper — applies a Fisher-Yates shuffle to `state.queue` iff enough + * time has passed since the last shuffle. Pure, returns a new state object. + * `currentTrack` is never touched. + */ +export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState { + if (state.settings?.autoShuffle === false) return state; + if (nowMs - state.lastShuffle < effectiveShuffleIntervalMs(state)) return state; + if (state.queue.length < 2) { + // Still bump `lastShuffle` so the next eligible shuffle is one full + // interval away, preventing a tight retry loop right after a guest + // drops a single item in. + return { ...state, lastShuffle: nowMs }; + } + const shuffled = state.queue.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return { ...state, queue: shuffled, lastShuffle: nowMs }; +} + +/** Drift between a guest's local playback and the host's estimated live position. */ +export function computeOrbitDriftMs(state: OrbitState, guestPositionMs: number, nowMs: number = Date.now()): number { + const hostEstimated = state.positionMs + (state.isPlaying ? (nowMs - state.positionAt) : 0); + return guestPositionMs - hostEstimated; +} + +// ── Host-side moderation ──────────────────────────────────────────────── + +/** + * Host: kick a participant by username. + * + * Appends the user to `kicked`, removes them from `participants`, deletes + * their outbox playlist (so a fresh re-create is recognised as a fresh + * attempt the gate blocks), and writes the new state immediately so the + * kicked guest notices on their very next poll rather than waiting for + * the regular sweep tick. + * + * Ignored if not the host, or if the session isn't active. + */ +export async function kickOrbitParticipant(username: string): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host') return; + const state = store.state; + const sessionPlaylistId = store.sessionPlaylistId; + const sid = store.sessionId; + if (!state || !sessionPlaylistId || !sid) return; + if (username === state.host) return; // host can't self-kick + if (state.kicked.includes(username)) return; // already kicked + + // 1) Delete the victim's outbox, best-effort. Finding it by name avoids + // carrying outbox ids in the state blob just for this operation. + const outboxName = orbitOutboxPlaylistName(sid, username); + try { + const all = await getPlaylists(true); + const hit = all.find(p => p.name === outboxName); + if (hit) await deletePlaylist(hit.id); + } catch { /* best-effort */ } + + // 2) Update state: append kick, drop from participants. Also strip any + // pending soft-`removed` marker for the same user — the permanent ban + // supersedes it. + const nextState: OrbitState = { + ...state, + kicked: [...state.kicked, username], + participants: state.participants.filter(p => p.user !== username), + removed: (state.removed ?? []).filter(r => r.user !== username), + }; + useOrbitStore.getState().setState(nextState); + try { + await writeOrbitState(sessionPlaylistId, nextState); + } catch { /* best-effort; next host tick will retry via its normal push */ } +} + +/** + * Host: soft-remove a participant by username. + * + * Like `kickOrbitParticipant`, but does NOT add the user to `kicked` — + * instead writes a short-lived entry to `removed`. The affected guest sees + * it on their next state-read tick and is shown a "you were removed" exit + * modal, but they are free to re-join immediately via the invite link. + * + * The marker ages out after `ORBIT_REMOVED_TTL_MS` in `applyOutboxSnapshotsToState`. + * + * Ignored if not the host, target is the host, target is permanently + * kicked, or the session isn't active. + */ +export async function removeOrbitParticipant(username: string): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host') return; + const state = store.state; + const sessionPlaylistId = store.sessionPlaylistId; + const sid = store.sessionId; + if (!state || !sessionPlaylistId || !sid) return; + if (username === state.host) return; + if (state.kicked.includes(username)) return; + + // 1) Delete outbox so the guest's next heartbeat-write hits a missing + // playlist (they'll create a new one on rejoin via joinOrbitSession). + const outboxName = orbitOutboxPlaylistName(sid, username); + try { + const all = await getPlaylists(true); + const hit = all.find(p => p.name === outboxName); + if (hit) await deletePlaylist(hit.id); + } catch { /* best-effort */ } + + // 2) Update state: drop from participants, append fresh `removed` marker. + // Filter any prior marker for the same user so we always carry the latest ts. + const now = Date.now(); + const nextState: OrbitState = { + ...state, + participants: state.participants.filter(p => p.user !== username), + removed: [ + ...(state.removed ?? []).filter(r => r.user !== username), + { user: username, at: now }, + ], + }; + useOrbitStore.getState().setState(nextState); + try { + await writeOrbitState(sessionPlaylistId, nextState); + } catch { /* best-effort */ } +} + +/** + * Host: mute/unmute a participant's track suggestions. + * + * Symmetric — pass `blocked: true` to add the username to + * `state.suggestionBlocked`, `false` to remove it. The participant remains + * in the session and continues to appear in the participants list; only new + * outbox entries are silently dropped during the host's sweep. The guest UI + * reads the same flag and disables its own Suggest controls so the user + * sees a clear "muted" state instead of silent failures. + * + * No-op outside host role, when the session isn't active, when the target + * is the host themselves, or when the toggle wouldn't change anything. + */ +export async function setOrbitSuggestionBlocked(username: string, blocked: boolean): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host') return; + const state = store.state; + const sessionPlaylistId = store.sessionPlaylistId; + if (!state || !sessionPlaylistId) return; + if (username === state.host) return; + + const current = state.suggestionBlocked ?? []; + const isBlocked = current.includes(username); + if (blocked === isBlocked) return; + + const nextList = blocked + ? [...current, username] + : current.filter(u => u !== username); + const nextState: OrbitState = { ...state, suggestionBlocked: nextList }; + useOrbitStore.getState().setState(nextState); + try { await writeOrbitState(sessionPlaylistId, nextState); } + catch { /* best-effort; next host tick will re-push state */ } +} + +/** + * Fold sweep results into an updated `OrbitState`. + * + * - New queue items are appended to `state.queue`, with `addedBy` = user + * and `addedAt` = now. Host-authored tracks (host's own currentTrack + * progression) are handled elsewhere and don't flow through this path. + * - `participants` is rebuilt from scratch from the sweep heartbeats — + * anyone with a fresh heartbeat (< `ORBIT_HEARTBEAT_ALIVE_MS` old) and + * not in `kicked` counts as alive. Users that disappear from the sweep + * age out naturally. + */ +export function applyOutboxSnapshotsToState( + state: OrbitState, + snapshots: OutboxSnapshot[], + nowMs: number = Date.now(), +): OrbitState { + // ── Queue additions ── + // Guest outboxes are append-only from the host's POV — the host reads the + // same playlist every sweep, so we must dedupe against anything already in + // `state.queue` (or currently playing) by (user, trackId). Without this, + // every host tick re-adds every outbox entry and the pending-approval list + // balloons indefinitely. A user re-suggesting the same track after it + // lands/plays is a rare enough case to live with for now. + const existingKeys = new Set( + state.queue.map(q => `${q.addedBy} ${q.trackId}`), + ); + if (state.currentTrack) { + existingKeys.add(`${state.currentTrack.addedBy} ${state.currentTrack.trackId}`); + } + + // Drop any new suggestion from a user the host has muted before the + // dedupe scan — they shouldn't count against the queue at all. + const blocked = new Set(state.suggestionBlocked ?? []); + const newItems: OrbitQueueItem[] = []; + for (const snap of snapshots) { + if (blocked.has(snap.user)) continue; + for (const trackId of snap.trackIds) { + const key = `${snap.user} ${trackId}`; + if (existingKeys.has(key)) continue; + existingKeys.add(key); + newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs }); + } + } + + // ── Soft-removed list aging ── + // Drop entries older than the TTL so the list stays bounded and a long- + // expired marker doesn't kick a freshly-rejoined user back out. + const removed = (state.removed ?? []).filter(r => nowMs - r.at < ORBIT_REMOVED_TTL_MS); + const removedUsers = new Set(removed.map(r => r.user)); + + // ── Participants rebuild ── + // Soft-removed users stay out of `participants` even if their heartbeat is + // still fresh — gives them up to one read tick (~2.5s) to notice the + // `removed`-marker and tear down their guest hooks before the marker ages out. + const prev = new Map(state.participants.map(p => [p.user, p])); + const participants: OrbitParticipant[] = []; + for (const snap of snapshots) { + if (state.kicked.includes(snap.user)) continue; + if (removedUsers.has(snap.user)) continue; + const fresh = snap.lastHeartbeat > 0 && (nowMs - snap.lastHeartbeat) < ORBIT_HEARTBEAT_ALIVE_MS; + if (!fresh) continue; + const existing = prev.get(snap.user); + participants.push({ + user: snap.user, + joinedAt: existing?.joinedAt ?? nowMs, + lastHeartbeat: snap.lastHeartbeat, + }); + } + + return { + ...state, + queue: newItems.length > 0 ? [...state.queue, ...newItems] : state.queue, + participants, + removed, + }; +} diff --git a/src/utils/orbitBulkGuard.ts b/src/utils/orbitBulkGuard.ts new file mode 100644 index 00000000..0881fe4c --- /dev/null +++ b/src/utils/orbitBulkGuard.ts @@ -0,0 +1,26 @@ +import { useOrbitStore } from '../store/orbitStore'; +import { useConfirmModalStore } from '../store/confirmModalStore'; +import i18n from '../i18n'; + +/** + * Ask the user before dropping many tracks into the shared Orbit queue. + * + * Returns `true` when there's no active Orbit session, when `count <= 1`, or + * when the user accepted the confirm dialog. Returns `false` only when an + * active-Orbit user explicitly cancelled. + * + * Lives in its own module so `playerStore` can use it without pulling the + * full `utils/orbit.ts` (which itself imports `playerStore` — circular). + */ +export async function orbitBulkGuard(count: number): Promise { + const role = useOrbitStore.getState().role; + if (role !== 'host' && role !== 'guest') return true; + if (count <= 1) return true; + + return useConfirmModalStore.getState().request({ + title: i18n.t('orbit.bulkConfirmTitle'), + message: i18n.t('orbit.bulkConfirmBody', { count }), + confirmLabel: i18n.t('orbit.bulkConfirmYes'), + cancelLabel: i18n.t('orbit.bulkConfirmNo'), + }); +} diff --git a/src/utils/orbitNames.ts b/src/utils/orbitNames.ts new file mode 100644 index 00000000..883ca538 --- /dev/null +++ b/src/utils/orbitNames.ts @@ -0,0 +1,85 @@ +/** + * Orbit — random session-name suggester. + * + * Combinatorial generator over three patterns: + * 1. `${adj} ${noun}` (e.g. "Velvet Orbit") + * 2. `${phrase} ${noun}` (e.g. "Late Night Rooftop") + * 3. `${noun} ${suffix}` (e.g. "Rooftop Sessions") + * + * Pools are hand-curated so that random combinations almost always still + * read as a coherent, music-themed session name. Total addressable name + * space is in the tens of thousands — you'd have to reshuffle very hard + * to see the same one twice. + */ + +const ADJECTIVES: readonly string[] = [ + 'Velvet', 'Neon', 'Midnight', 'Golden', 'Static', 'Cosmic', + 'Late', 'Lost', 'Deep', 'Slow', 'Quiet', 'Electric', + 'Analog', 'Distant', 'Hidden', 'Frozen', 'Warm', 'Wild', + 'Silver', 'Amber', 'Crystal', 'Endless', 'Hazy', 'Drifting', + 'Restless', 'Secret', 'Weightless', 'Echoing', 'Low', 'Bright', + 'Soft', 'Dusty', 'Foggy', 'Smoky', 'Twilight', 'Dawning', + 'Infinite', 'Eternal', 'Vintage', 'Smooth', 'Silent', 'Faint', + 'Bold', 'Sharp', 'Tender', 'Savage', 'Gentle', 'Reckless', + 'Chill', 'Steaming', 'Burning', 'Icy', 'Muted', 'Vivid', + 'Prismatic', 'Shadowy', 'Liminal', 'Spectral', 'Faded', 'Sleepy', + 'Wandering', 'Roaming', 'Dreaming', 'Floating', 'Buzzing', 'Rolling', + 'Hushed', 'Broken', 'Wired', 'Outer', 'Moonlit', 'Sunlit', + 'Firelit', 'Candlelit', 'Quiet', 'Howling', 'Whispered', 'Shimmering', + 'Dusky', 'Drowsy', 'Plush', 'Opalescent', 'Silken', +]; + +const NOUNS: readonly string[] = [ + // places + 'Rooftop', 'Kitchen', 'Basement', 'Garage', 'Lounge', 'Diner', + 'Cinema', 'Harbor', 'Highway', 'Hotel', 'Parlor', 'Attic', + 'Balcony', 'Terrace', 'Patio', 'Studio', 'Warehouse', 'Pier', + 'Terminal', 'Platform', 'Corner', 'Boulevard', 'Tower', 'Lighthouse', + 'Chapel', 'Bunker', 'Courtyard', 'Observatory', 'Arcade', 'Alleyway', + // media / musical + 'Tape', 'Radio', 'Session', 'Rotation', 'Mixtape', 'Transmission', + 'Frequency', 'Broadcast', 'Channel', 'Cassette', 'Reel', 'Loop', + 'Vinyl', 'Sleeve', 'Waveform', 'Echo', 'Reverb', 'Bassline', + 'Bridge', 'Interlude', 'Mix', 'Playlist', 'Chord', 'Groove', + 'Encore', 'Setlist', 'Tracklist', 'Dub', 'Bootleg', + // celestial / orbit-y + 'Orbit', 'Galaxy', 'Nebula', 'Comet', 'Horizon', 'Signal', + 'Drift', 'Satellite', 'Atmosphere', 'Starfield', 'Eclipse', 'Nova', + 'Moon', 'Void', 'Prism', 'Meteor', 'Solstice', 'Equinox', + 'Zenith', 'Apogee', 'Pulsar', 'Quasar', 'Aurora', 'Supernova', +]; + +const PHRASES: readonly string[] = [ + 'Late Night', 'Deep Space', 'Low-Fi', 'Low-Key', 'Slow Burn', + 'Afterhour', 'Golden Hour', 'Blue Hour', 'Off-Grid', 'Outer Space', + 'Northern Light', 'Velvet Night', 'Neon Drive', 'Midnight Drive', + 'Quiet Storm', 'Slow Motion', 'Electric Dream', 'Analog Dream', + 'Hidden Track', 'Side-B', 'Dark-Side', 'Back-Room', 'Morning-After', + 'Last-Call', 'First-Light', 'Long-Play', 'Cold-Start', 'Warm-Up', + 'Sunset', 'Afterparty', +]; + +const SUFFIXES: readonly string[] = [ + 'Sessions', 'Radio', 'Tapes', 'Transmissions', 'Rotations', + 'Mixes', 'Broadcasts', 'Frequencies', 'Interludes', 'Playback', + 'Nights', 'Hours', 'Signals', 'Takes', 'Bootlegs', +]; + +function pickRandom(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]; +} + +/** Returns a fresh combinatorial suggestion. */ +export function randomOrbitSessionName(): string { + const r = Math.random(); + if (r < 0.20) { + // "Rooftop Sessions" + return `${pickRandom(NOUNS)} ${pickRandom(SUFFIXES)}`; + } + if (r < 0.40) { + // "Late Night Rooftop" + return `${pickRandom(PHRASES)} ${pickRandom(NOUNS)}`; + } + // Default: "Velvet Orbit" + return `${pickRandom(ADJECTIVES)} ${pickRandom(NOUNS)}`; +} diff --git a/src/utils/playAlbum.ts b/src/utils/playAlbum.ts index 3e47b600..3e6b7e65 100644 --- a/src/utils/playAlbum.ts +++ b/src/utils/playAlbum.ts @@ -1,6 +1,7 @@ import { getAlbum } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { songToTrack } from '../store/playerStore'; +import { useOrbitStore } from '../store/orbitStore'; function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise { return new Promise(resolve => { @@ -28,6 +29,16 @@ export async function playAlbum(albumId: string): Promise { }); if (!tracks.length) return; + // In Orbit sessions, playAlbum is effectively an append operation (the + // playerStore bulk-gate also routes replaces into enqueue). Skip the + // fadeOut entirely — the current track keeps playing, the album goes + // onto the end of the queue after the user confirms the bulk dialog. + const orbitRole = useOrbitStore.getState().role; + if (orbitRole === 'host' || orbitRole === 'guest') { + usePlayerStore.getState().enqueue(tracks); + return; + } + const store = usePlayerStore.getState(); const { isPlaying, volume } = store; diff --git a/src/utils/playSong.ts b/src/utils/playSong.ts new file mode 100644 index 00000000..a1ecf2d1 --- /dev/null +++ b/src/utils/playSong.ts @@ -0,0 +1,61 @@ +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import type { SubsonicSong } from '../api/subsonic'; + +function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise { + return new Promise(resolve => { + const steps = 16; + const stepMs = durationMs / steps; + let step = 0; + const id = setInterval(() => { + step++; + setVolume(Math.max(0, from * (1 - step / steps))); + if (step >= steps) { + clearInterval(id); + resolve(); + } + }, stepMs); + }); +} + +/** + * Play a single song. When `queue` is provided, surrounds the chosen song with that queue + * so Next/Prev work — pass the rail / pool the click came from. Mirrors playAlbum's fade-out. + */ +export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): Promise { + const track = songToTrack(song); + const tracks = queue && queue.length > 0 + ? queue.map(songToTrack) + : [track]; + + const store = usePlayerStore.getState(); + const { isPlaying, volume } = store; + + if (isPlaying) { + await fadeOut(store.setVolume, volume, 700); + usePlayerStore.setState({ volume }); + } + + usePlayerStore.getState().playTrack(track, tracks); +} + +/** + * Append the song to the existing queue (if not already there) and immediately jump to it. + * Existing queue stays intact — different from playSongNow which replaces the queue. + */ +export async function enqueueAndPlay(song: SubsonicSong): Promise { + const track = songToTrack(song); + const store = usePlayerStore.getState(); + const { isPlaying, volume, queue } = store; + + if (isPlaying) { + await fadeOut(store.setVolume, volume, 700); + usePlayerStore.setState({ volume }); + } + + if (!queue.some(t => t.id === track.id)) { + usePlayerStore.getState().enqueue([track]); + } + // playTrack with no queue arg uses the current state.queue, finds the track by id, + // and sets queueIndex accordingly. + usePlayerStore.getState().playTrack(track); +} diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts index ea7feded..36d04a36 100644 --- a/src/utils/shareLink.ts +++ b/src/utils/shareLink.ts @@ -5,10 +5,17 @@ export const PSYSONIC_SHARE_PREFIX = 'psysonic2-'; export type EntityShareKind = 'track' | 'album' | 'artist'; -export type SharePayloadV1 = +/** Entity / queue shares — what {@link applySharePastePayload} dispatches on. */ +export type EntitySharePayloadV1 = | { srv: string; k: EntityShareKind; id: string } | { srv: string; k: 'queue'; ids: string[] }; +/** Orbit invite — session id + originating server. Decoded separately so that + * entity-share consumers can't accidentally receive an orbit payload. */ +export type OrbitSharePayloadV1 = { srv: string; k: 'orbit'; sid: string }; + +export type SharePayloadV1 = EntitySharePayloadV1 | OrbitSharePayloadV1; + export function normalizeShareServerUrl(url: string): string { const t = url.trim(); if (!t) return ''; @@ -38,24 +45,38 @@ function isEntityKind(k: unknown): k is EntityShareKind { export function encodeSharePayload(payload: SharePayloadV1): string { const srvNorm = normalizeShareServerUrl(payload.srv); - const body = - payload.k === 'queue' - ? JSON.stringify({ - v: 1, - srv: srvNorm, - k: 'queue', - ids: payload.ids.map(id => String(id).trim()).filter(Boolean), - }) - : JSON.stringify({ - v: 1, - srv: srvNorm, - k: payload.k, - id: String(payload.id).trim(), - }); + let body: string; + if (payload.k === 'queue') { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: 'queue', + ids: payload.ids.map(id => String(id).trim()).filter(Boolean), + }); + } else if (payload.k === 'orbit') { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: 'orbit', + sid: String(payload.sid).trim(), + }); + } else { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: payload.k, + id: String(payload.id).trim(), + }); + } return PSYSONIC_SHARE_PREFIX + utf8ToBase64Url(body); } -export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null { +/** + * Decode an entity / queue share from pasted text. Returns null for orbit + * payloads (use {@link decodeOrbitSharePayloadFromText}) — so entity-share + * consumers can't be fed an orbit invite by accident. + */ +export function decodeSharePayloadFromText(text: string): EntitySharePayloadV1 | null { const idx = text.indexOf(PSYSONIC_SHARE_PREFIX); if (idx < 0) return null; const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length); @@ -67,6 +88,7 @@ export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : ''; if (!srv) return null; const k = raw.k; + if (k === 'orbit') return null; if (k === 'queue') { const idsRaw = raw.ids; if (!Array.isArray(idsRaw) || idsRaw.length === 0) return null; @@ -87,3 +109,24 @@ export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: stri const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm); return hit?.id ?? null; } + +/** Decode an orbit invite from pasted text. Returns null for entity / queue shares. */ +export function decodeOrbitSharePayloadFromText(text: string): OrbitSharePayloadV1 | null { + const idx = text.indexOf(PSYSONIC_SHARE_PREFIX); + if (idx < 0) return null; + const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length); + const token = after.match(/^([A-Za-z0-9_-]+)/)?.[1]; + if (!token) return null; + try { + const raw = JSON.parse(base64UrlToUtf8(token)) as Record; + if (raw.v !== 1) return null; + if (raw.k !== 'orbit') return null; + const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : ''; + if (!srv) return null; + const sid = typeof raw.sid === 'string' ? raw.sid.trim().toLowerCase() : ''; + if (!/^[0-9a-f]{8}$/.test(sid)) return null; + return { srv, k: 'orbit', sid }; + } catch { + return null; + } +} diff --git a/src/utils/switchActiveServer.ts b/src/utils/switchActiveServer.ts index fe13552c..555cd4c6 100644 --- a/src/utils/switchActiveServer.ts +++ b/src/utils/switchActiveServer.ts @@ -1,11 +1,31 @@ import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import type { ServerProfile } from '../store/authStore'; import { useAuthStore } from '../store/authStore'; +import { useOrbitStore } from '../store/orbitStore'; +import { endOrbitSession, leaveOrbitSession } from './orbit'; export async function switchActiveServer(server: ServerProfile): Promise { try { const ping = await pingWithCredentials(server.url, server.username, server.password); if (!ping.ok) return false; + + // Tear down any active Orbit session before we actually switch. The + // session's playlists live on the *old* server — once we flip the + // active server, every API call from the orbit hooks would hit the + // wrong backend, heartbeats would silently fail, and the next + // app-start cleanup would prune the still-live session as stale. + // Capped at 1.5 s so a slow network doesn't freeze the UI. + const role = useOrbitStore.getState().role; + if (role === 'host' || role === 'guest') { + const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession(); + await Promise.race([ + teardown.catch(() => {}), + new Promise(r => setTimeout(r, 1500)), + ]); + // Ensure local store is idle even if the remote call timed out. + useOrbitStore.getState().reset(); + } + const identity = { type: ping.type, serverVersion: ping.serverVersion,