From f3dc4415bbdfd853584df43e6451bb836205c536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 03:13:16 +0000 Subject: [PATCH] music: per-device now-playing (browser vs phone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key music_now_playing on (user_id, device) instead of user_id alone so the browser ('web') and phone ('' default) each keep their own resume snapshot instead of sharing one row. Sidecar /now-playing threads device (?device=, default '') through get/set/clearNowPlaying; the web client tags its calls ?device=web. Phone unchanged → '' bucket, inherits the existing row. Co-Authored-By: Claude Opus 4.8 --- src/databases/officer_db/src/queries/music.ts | 19 ++++---- src/databases/officer_db/src/schema/music.ts | 43 +++++++++++-------- src/servers/sidecar/music/index.ts | 15 ++++--- .../src/MusicPlayer/MusicPlayerHost.tsx | 6 +-- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/databases/officer_db/src/queries/music.ts b/src/databases/officer_db/src/queries/music.ts index aab4ecad..56c111e1 100644 --- a/src/databases/officer_db/src/queries/music.ts +++ b/src/databases/officer_db/src/queries/music.ts @@ -54,8 +54,8 @@ export type NowPlayingInput = { positionSec?: number; }; -/** The user's last "currently playing" snapshot, or null if none. */ -export async function getNowPlaying(userId: number): Promise { +/** This (user, device)'s last "currently playing" snapshot, or null if none. */ +export async function getNowPlaying(userId: number, device: string): Promise { const [row] = await db .select({ homePath: musicNowPlaying.homePath, @@ -68,14 +68,15 @@ export async function getNowPlaying(userId: number): Promise updatedAt: musicNowPlaying.updatedAt, }) .from(musicNowPlaying) - .where(eq(musicNowPlaying.userId, userId)); + .where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device))); return row ?? null; } -/** Upsert the user's "currently playing" snapshot (one row per user). */ -export async function setNowPlaying(userId: number, np: NowPlayingInput): Promise { +/** Upsert this (user, device)'s "currently playing" snapshot (one row per user+device). */ +export async function setNowPlaying(userId: number, device: string, np: NowPlayingInput): Promise { const values = { userId, + device, homePath: np.homePath, dir: np.dir ?? '', title: np.title ?? '', @@ -89,7 +90,7 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis .insert(musicNowPlaying) .values(values) .onConflictDoUpdate({ - target: musicNowPlaying.userId, + target: [musicNowPlaying.userId, musicNowPlaying.device], set: { homePath: values.homePath, dir: values.dir, @@ -103,9 +104,9 @@ export async function setNowPlaying(userId: number, np: NowPlayingInput): Promis }); } -/** Clear the user's "currently playing" (on close/stop). */ -export async function clearNowPlaying(userId: number): Promise { - await db.delete(musicNowPlaying).where(eq(musicNowPlaying.userId, userId)); +/** Clear this (user, device)'s "currently playing" (on close/stop). */ +export async function clearNowPlaying(userId: number, device: string): Promise { + await db.delete(musicNowPlaying).where(and(eq(musicNowPlaying.userId, userId), eq(musicNowPlaying.device, device))); } // ── Named playlists ──────────────────────────────────────────────────────────────────────────────── diff --git a/src/databases/officer_db/src/schema/music.ts b/src/databases/officer_db/src/schema/music.ts index 8d4a8c0f..6d598846 100644 --- a/src/databases/officer_db/src/schema/music.ts +++ b/src/databases/officer_db/src/schema/music.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, integer, text, real, timestamp, unique, index } from 'drizzle-orm/pg-core'; +import { pgTable, serial, integer, text, real, timestamp, unique, index, primaryKey } from 'drizzle-orm/pg-core'; import { users } from './auth'; // Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets: @@ -54,20 +54,27 @@ export const musicPlaylistItems = pgTable( (t) => [index('idx_music_playlist_items_playlist').on(t.playlistId, t.position)], ); -// Per-user "currently playing" for resume-across-launch/device: the current track + playback position, -// plus a light metadata snapshot so the resume card renders before the library index has synced on a -// fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album queue). One -// row per user (upserted; the app writes it throttled while playing and on pause/track-change/close). -export const musicNowPlaying = pgTable('music_now_playing', { - userId: integer('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - homePath: text('home_path').notNull(), - dir: text('dir').notNull().default(''), - title: text('title').notNull().default(''), - artist: text('artist').notNull().default(''), - album: text('album').notNull().default(''), - durationSec: real('duration_sec').notNull().default(0), - positionSec: real('position_sec').notNull().default(0), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +// Per-(user, device) "currently playing" for resume-across-launch: the current track + playback +// position, plus a light metadata snapshot so the resume card renders before the library index has +// synced on a fresh device. `dir` is the folder to rebuild the album queue from ('' for a cross-album +// queue). `device` is an opaque client tag ('' = default/phone, 'web' = the browser) so each client +// keeps its OWN resume state instead of stomping a shared one. One row per (user, device) — upserted; +// the app writes it throttled while playing and on pause/track-change/close. +export const musicNowPlaying = pgTable( + 'music_now_playing', + { + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + device: text('device').notNull().default(''), + homePath: text('home_path').notNull(), + dir: text('dir').notNull().default(''), + title: text('title').notNull().default(''), + artist: text('artist').notNull().default(''), + album: text('album').notNull().default(''), + durationSec: real('duration_sec').notNull().default(0), + positionSec: real('position_sec').notNull().default(0), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.userId, t.device] })], +); diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts index 94e7173c..0c60c4b5 100644 --- a/src/servers/sidecar/music/index.ts +++ b/src/servers/sidecar/music/index.ts @@ -98,9 +98,9 @@ const asKeys = (v: unknown): string[] | null => // GET /favorites { tracks[], albums[], artists[] } (keys, newest first). // POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist. // DELETE /favorites?kind=&key= remove. -// GET /now-playing last snapshot, or null. -// PUT /now-playing { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert. -// DELETE /now-playing clear. +// GET /now-playing[?device=] last snapshot for that device, or null. device '' = default/phone, 'web' = browser. +// PUT /now-playing[?device=] { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert. +// DELETE /now-playing[?device=] clear that device's snapshot. // GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first). // POST /playlists { name } create → 201 row; 409 if name taken. // GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's. @@ -341,14 +341,17 @@ const server = Bun.serve({ } if (P === '/now-playing') { - if (m === 'GET') return json(await getNowPlaying(uid)); + // Per-client resume state: `?device=` tags the caller ('' = default/phone, 'web' = the browser) + // so each keeps its own now-playing instead of sharing one row. Missing → '' (back-compat). + const device = url.searchParams.get('device') ?? ''; + if (m === 'GET') return json(await getNowPlaying(uid, device)); if (m === 'PUT') { const b = (await req.json().catch(() => ({}))) as Record; if (typeof b.homePath !== 'string' || !b.homePath) return json({ error: 'homePath required' }, { status: 400 }); const str = (v: unknown) => (typeof v === 'string' ? v : undefined); const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); - await setNowPlaying(uid, { + await setNowPlaying(uid, device, { homePath: b.homePath, dir: str(b.dir), title: str(b.title), @@ -360,7 +363,7 @@ const server = Bun.serve({ return json({ ok: true }); } if (m === 'DELETE') { - await clearNowPlaying(uid); + await clearNowPlaying(uid, device); return json({ ok: true }); } return new Response('Method not allowed', { status: 405 }); diff --git a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx index 04fdab45..d58922b3 100644 --- a/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx +++ b/src/workspaces/officerdev/src/MusicPlayer/MusicPlayerHost.tsx @@ -76,7 +76,7 @@ export const MusicPlayerHost = () => { const persist = () => { if (!current) return; - put('/music/now-playing', { + put('/music/now-playing?device=web', { homePath: trackHomePath(current.albumRel, current.file), dir: `Music/${current.albumRel}`, title: current.title ?? '', @@ -126,7 +126,7 @@ export const MusicPlayerHost = () => { restoredRef.current = true; if (queue.length) return; (async () => { - const snap = await get('/music/now-playing').catch(() => null); + const snap = await get('/music/now-playing?device=web').catch(() => null); if (!snap?.homePath) return; const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, ''); const file = snap.homePath.split('/').pop() ?? ''; @@ -236,7 +236,7 @@ export const MusicPlayerHost = () => { // Closing the dock also clears the saved "currently playing" snapshot, so it doesn't get restored on // the next load. (Merely close()-ing the local queue would leave the server snapshot to bring it back.) const handleClose = () => { - del('/music/now-playing').catch(() => {}); + del('/music/now-playing?device=web').catch(() => {}); close(); };