From a528cad33768487d29e1c1ab6f62433dbd2a2853 Mon Sep 17 00:00:00 2001 From: LethaboK Date: Mon, 28 Sep 2026 05:04:38 +0200 Subject: [PATCH 1/2] docs(roadmap): record deployment-profiles plan for bring-your-own infra Open-source deployers range from 'just run it' to 'attach my existing database resources'. Sketch the three profiles (zero-config, batteries-included, partial attach) with the Doctor as validator, deferred until the current roadmap ships. --- docs/ROADMAP.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 912b7ef..c815c90 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -543,6 +543,26 @@ reappear. --- +## Deployment profiles — bring-your-own infrastructure (planned) + +Rheoson is open source; the people deploying it have wildly different +resources. The plan is a small set of deployment profiles that map one +configuration file onto what the deployer actually has: + +- **Zero-config** — point Rheoson at an existing Postgres/Mongo/Redis (or the + equivalents it can reach) and it wires itself up on first boot. +- **Batteries-included** — one command brings up the full stack with its own + containers, for people who just want it running. +- **Partial attach** — reuse some managed resources (a cloud database) and + self-host the rest, with the health report making the mix explicit. + +The Doctor's health probes become the validator: after attaching, a profile +check confirms each resource is reachable, schema-current and writable +before the app accepts traffic. Deliberately deferred until the current +roadmap ships — recorded here so it isn't lost. + +--- + **Guardrails for every phase:** `tsc`, `eslint`, the full vitest and pytest suites, a production build and the API-base check all pass before the commit; existing settings, themes and preference sync keep working; the APK still From 193596c52ace0a622b1894586373b56382ee6051 Mon Sep 17 00:00:00 2001 From: LethaboK Date: Mon, 28 Sep 2026 11:41:56 +0200 Subject: [PATCH 2/2] feat(ux): v2.22.3 streaming/UX audit fixes, Spotify bulk actions, demo bot - Toast: stop long error text bleeding off-screen (min-w-0 + break-words) - Home: cap the analytics query and fall back to recently-played so 'Based on your listening' resolves instead of skeletoning forever; artist circles show real images - Messages: demo bot (typing indicator + song cards) so chat layout is testable without a second account - Queue: converted to a bottom sheet - NowPlaying: creator content prefetch on track change, lyrics warmed for the next queue tracks, lyrics share via native share/clipboard, lyrics text-size control replaces the auto-scroll toggle - Search: resolved Spotify/YouTube links get 'Save all as playlist' (one-call create-and-fill via new seed trackIds) and 'Download all' - Artwork rendering unified on ArtworkImage across home, queue, messages, artist rows; like(s) copy renamed to Save --- api/app/main.py | 2 +- api/app/routers/playlist_router.py | 5 +- api/app/schemas/playlist_schema.py | 3 + api/pyproject.toml | 2 +- api/tests/test_playlists.py | 20 ++ api/uv.lock | 2 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/api/playlists.api.ts | 26 ++ web/src/components/player/PlayerBar.tsx | 6 +- web/src/components/player/QueuePanel.tsx | 30 +- web/src/components/track/TrackContextMenu.tsx | 6 +- web/src/components/ui/Toast.tsx | 9 +- web/src/hooks/search.hook.ts | 13 +- web/src/lib/constants.ts | 2 +- web/src/pages/album/Album.tsx | 2 +- web/src/pages/artist/Artist.tsx | 15 +- web/src/pages/blend/Blend.tsx | 3 +- .../downloads/components/LibraryTrackRow.tsx | 2 +- web/src/pages/home/Home.tsx | 64 +++- web/src/pages/home/components/Featured.tsx | 13 +- web/src/pages/library/Library.tsx | 8 +- web/src/pages/messages/Messages.tsx | 335 +++++++++++++++++- web/src/pages/nowplaying/NowPlaying.tsx | 143 ++++++-- web/src/pages/profile/Profile.tsx | 2 +- web/src/pages/search/Search.tsx | 72 +++- .../pages/settings/sections/AboutSection.tsx | 2 +- .../pages/settings/sections/StatsSection.tsx | 8 +- web/src/pages/stats/ListeningStats.tsx | 2 +- web/src/types/api-generated.ts | 5 + web/src/types/openapi.json | 10 +- 31 files changed, 704 insertions(+), 114 deletions(-) diff --git a/api/app/main.py b/api/app/main.py index cf7d7b5..1744168 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -53,7 +53,7 @@ # ── Startup validation ──────────────────────────────────────── validate_startup() -VERSION = "2.22.2" +VERSION = "2.22.3" # ── CORS ────────────────────────────────────────────────────── diff --git a/api/app/routers/playlist_router.py b/api/app/routers/playlist_router.py index 3d634f2..7d07e08 100644 --- a/api/app/routers/playlist_router.py +++ b/api/app/routers/playlist_router.py @@ -262,11 +262,12 @@ async def create_playlist( ): user_id = user["sub"] data = _load(user_id) - pl = _new_playlist(user_id, req.title, req.description or "", []) + pl = _new_playlist(user_id, req.title, req.description or "", list(req.trackIds or [])) data[pl["id"]] = pl _save(user_id, data) await _sync_mongo(pl) - return pl + # Seeds stay as IDs in storage; the list shape carries no hydrated tracks. + return _summarize(pl) @router.patch("/{playlist_id}", response_model=PlaylistSchema) diff --git a/api/app/schemas/playlist_schema.py b/api/app/schemas/playlist_schema.py index 38dac45..c67a9b8 100644 --- a/api/app/schemas/playlist_schema.py +++ b/api/app/schemas/playlist_schema.py @@ -45,6 +45,9 @@ def _coerce_duration(cls, v): class CreatePlaylistSchema(BaseModel): title: str description: str = "" + # Optional seed tracks — lets a client create-and-fill in one round trip + # (e.g. "save a resolved Spotify playlist"). Order is preserved. + trackIds: list[str] = [] class UpdatePlaylistSchema(BaseModel): diff --git a/api/pyproject.toml b/api/pyproject.toml index 41b1491..1fe1814 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rheoson-api" -version = "2.22.2" +version = "2.22.3" requires-python = ">=3.13" license = "Apache-2.0" dependencies = [ diff --git a/api/tests/test_playlists.py b/api/tests/test_playlists.py index b54ee54..df7eec3 100644 --- a/api/tests/test_playlists.py +++ b/api/tests/test_playlists.py @@ -28,6 +28,26 @@ async def test_create_playlist(client): assert data["trackCount"] == 0 +@pytest.mark.asyncio +async def test_create_playlist_with_seed_tracks(client): + """POST /playlists with optional seed trackIds creates-and-fills in one + call (the "save a resolved Spotify playlist" path). Order is preserved.""" + resp = await client.post("/api/playlists", json={ + "title": "Seeded", + "trackIds": ["vid-1", "vid-2", "vid-3"], + }) + assert resp.status_code == 201 + data = resp.json() + assert data["trackCount"] == 3 + assert data["trackIds"] == ["vid-1", "vid-2", "vid-3"] + + # The detail endpoint hydrates the same seeds back out. + detail = await client.get(f"/api/playlists/{data['id']}") + assert detail.status_code == 200 + hydrated = detail.json() + assert [t["id"] for t in hydrated["tracks"]] == ["vid-1", "vid-2", "vid-3"] + + @pytest.mark.asyncio async def test_create_playlist_requires_title(client): """POST /playlists without title should return 422.""" diff --git a/api/uv.lock b/api/uv.lock index eb109e9..da3459f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1071,7 +1071,7 @@ wheels = [ [[package]] name = "rheoson-api" -version = "2.22.2" +version = "2.22.3" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, diff --git a/web/package-lock.json b/web/package-lock.json index 652e6b3..ac25eb2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "rheoson-web", - "version": "2.22.2", + "version": "2.22.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rheoson-web", - "version": "2.22.2", + "version": "2.22.3", "license": "Apache-2.0", "dependencies": { "@capacitor/android": "^8.4.1", diff --git a/web/package.json b/web/package.json index e12f243..b884872 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "rheoson-web", "private": true, - "version": "2.22.2", + "version": "2.22.3", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/web/src/api/playlists.api.ts b/web/src/api/playlists.api.ts index e441024..e52d327 100644 --- a/web/src/api/playlists.api.ts +++ b/web/src/api/playlists.api.ts @@ -155,6 +155,31 @@ export const playlistsApi = { } }, + /** Create a playlist pre-filled with tracks in one round trip (the + * backend accepts optional seed `trackIds`). Falls back to + * create-then-add when the backend predates seed support. */ + createBulk: async (data: { title: string; description?: string; trackIds: string[] }): Promise => { + try { + const remote = await api.postQueued('/playlists', { + title: data.title, + description: data.description ?? '', + trackIds: data.trackIds, + }); + if (remote?.id) { + await playlistsStore.put({ ...remote, tracks: [] }); + return remote; + } + } catch { + // Fall through to the offline path below. + } + // Offline / seed unsupported: create locally, then queue the adds. + const pl = await playlistsApi.createPlaylist({ title: data.title, description: data.description }); + for (const id of data.trackIds) { + await playlistsApi.addTrack(pl.id, id); + } + return pl; + }, + removeTrack: async (playlistId: string, trackId: string): Promise => { // Optimistic local update const local = await playlistsStore.get(playlistId); @@ -221,6 +246,7 @@ export const { getPlaylists, getPlaylist, createPlaylist, + createBulk, updatePlaylist, deletePlaylist, addTrack, diff --git a/web/src/components/player/PlayerBar.tsx b/web/src/components/player/PlayerBar.tsx index 3e21cce..a34c32e 100644 --- a/web/src/components/player/PlayerBar.tsx +++ b/web/src/components/player/PlayerBar.tsx @@ -191,7 +191,7 @@ export default function PlayerBar() { - {/* Panel */} + {/* Bottom sheet — rises from the bottom edge on every screen + size; a side panel fought the thumb-reach and covered the + player bar it describes. Drag-handle + rounded top keep it + reading as a sheet, not a dialog. */} + {/* Drag handle affordance */} +
+ +
+ {/* Header */} -
+

Queue

diff --git a/web/src/components/track/TrackContextMenu.tsx b/web/src/components/track/TrackContextMenu.tsx index 469c24a..964dfeb 100644 --- a/web/src/components/track/TrackContextMenu.tsx +++ b/web/src/components/track/TrackContextMenu.tsx @@ -105,7 +105,7 @@ function useMenuActions(track: Track, onClose: () => void) { }, { id: 'like', - label: track.isLiked ? 'Remove from favourites' : 'Favourite', + label: track.isLiked ? 'Removed from saved' : 'Save', icon: ( void) { run: async () => { if (track.isLiked) { await tracksApi.unlikeTrack(track.id) - toast('Removed from favourites', 'info', 1800) + toast('Removed from saved', 'info', 1800) } else { await tracksApi.likeTrack(track.id) - toast('Added to favourites', 'success', 1800) + toast('Saved', 'success', 1800) } refreshLikes() onClose() diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx index 1a3d578..6a5320e 100644 --- a/web/src/components/ui/Toast.tsx +++ b/web/src/components/ui/Toast.tsx @@ -57,15 +57,18 @@ export function Toast({ id, type, message, code, fullDetail, onDismiss }: ToastP exit={{ opacity: 0, y: -10, scale: 0.95 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }} className={cn( - 'flex flex-col gap-1 px-4 py-3 rounded-2xl shadow-xl border', + 'flex flex-col gap-1 px-4 py-3 rounded-2xl shadow-xl border overflow-hidden', 'glass-strong min-w-[280px] max-w-sm', styles[type] )} role={type === 'error' ? 'alert' : 'status'} > -
+
{icons[type]} -

{message}

+ {/* min-w-0 + overflow-wrap:anywhere — a long unbroken token (a URL, + yt-dlp noise) must wrap inside the toast instead of pushing the + flex item past the viewport edge. */} +

{message}

{hasMore && ( - + - {track.artworkUrl - ? {track.title} - : ( -
- -
- ) - } +

{track.title}

diff --git a/web/src/pages/blend/Blend.tsx b/web/src/pages/blend/Blend.tsx index a6d0a7d..17b0c31 100644 --- a/web/src/pages/blend/Blend.tsx +++ b/web/src/pages/blend/Blend.tsx @@ -20,6 +20,7 @@ import { messagesApi, type PeerInfo } from '@/api/messages.api' import { qk } from '@/lib/queryKeys' import { useAuthStore } from '@/store/auth.store' import { useQueue } from '@/hooks/queue.hook' +import { ArtworkImage } from '@/components/ui/ArtworkImage' import { useToast } from '@/components/ui/Toaster' import { Skeleton } from '@/components/ui/Skeleton' import { splitErrorCode } from '@/api/client.api' @@ -281,7 +282,7 @@ export default function Blend() {
@@ -898,12 +898,12 @@ export default function Library() { ))} {!loadingLiked && visibleLiked.length === 0 && ( - q ? noMatches('favourites', ) : ( + q ? noMatches('saved', ) : ( } - title='No favourites yet' + title='Nothing saved yet' note='Tap the heart on any song to save it here' /> ) diff --git a/web/src/pages/messages/Messages.tsx b/web/src/pages/messages/Messages.tsx index 8ba73bf..987888e 100644 --- a/web/src/pages/messages/Messages.tsx +++ b/web/src/pages/messages/Messages.tsx @@ -14,12 +14,14 @@ import { } from '@phosphor-icons/react' import { messagesApi, type ChatMessage, type Conversation, type PresenceUser } from '@/api/messages.api' import { tracksApi } from '@/api/tracks.api' +import { searchApi } from '@/api/search.api' import { qk } from '@/lib/queryKeys' import { useAuthStore } from '@/store/auth.store' import { useWebSocket } from '@/lib/websocket.lib' import { useQueue } from '@/hooks/queue.hook' import { useToast } from '@/components/ui/Toaster' import { Skeleton } from '@/components/ui/Skeleton' +import { ArtworkImage } from '@/components/ui/ArtworkImage' import { splitErrorCode } from '@/api/client.api' import { cn } from '@/lib/utils' import type { Track } from '@/types/track.types' @@ -61,7 +63,17 @@ export default function Messages() { return (
- {activePeer ? ( + {activePeer === DEMO_BOT_ID ? ( + + setActivePeer(null)} /> + + ) : activePeer ? ( void ) } +// ── Demo chat bot ───────────────────────────────────────────── + +/** + * Rheo — an on-device demo companion. + * + * When the deployment has no MongoDB (the common case for a Termux or + * first-run instance), the real messaging service has nothing to stand + * on: there are no peers to talk to, so the whole surface looks broken. + * Rheo is a purely client-side conversation that exercises every message + * shape — text, typing indicator, a shared track card that really plays, + * a lyrics card — so the layout can be reviewed without a backend. + * + * Rheo never touches the network and only appears in the list; opening + * the thread is a local view. Nothing here is persisted or sent. + */ + +const DEMO_BOT_ID = 'demo-rheo' + +const DEMO_REPLIES: string[] = [ + 'Nice to meet you! I am a local demo — nothing I say leaves this device.', + 'I can share songs too — tap the card below to hear how it plays.', + 'Try the typing indicator… it should feel like a real chat.', + 'When MongoDB is running, real friends show up right here instead.', +] + +function _demoId(): string { + return `demo-${Date.now()}-${Math.floor(Math.random() * 1e6)}` +} + +function DemoBotThread({ onBack }: { onBack: () => void }) { + const { playTrack } = useQueue() + const navigate = useNavigate() + const [messages, setMessages] = useState([ + { + id: _demoId(), + conversationId: DEMO_BOT_ID, + senderId: DEMO_BOT_ID, + kind: 'text', + text: 'Hey! I am Rheo 👋 — a demo chat built into the app.', + payload: {}, + createdAt: new Date().toISOString(), + }, + ]) + const [draft, setDraft] = useState('') + const [botTyping, setBotTyping] = useState(false) + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages.length, botTyping]) + + const _demoSong = async (): Promise => { + try { + const results = await searchApi.search('daft punk', 'tracks') + const t = results.tracks?.[0] + if (!t) return null + return { + id: _demoId(), + conversationId: DEMO_BOT_ID, + senderId: DEMO_BOT_ID, + kind: 'track', + text: t.title, + payload: { + trackId: t.id, + title: t.title, + subtitle: t.artist?.name ?? undefined, + artworkUrl: t.artworkUrl ?? undefined, + }, + createdAt: new Date().toISOString(), + } + } catch { + return null + } + } + + const send = () => { + const text = draft.trim() + if (!text) return + const mine: ChatMessage = { + id: _demoId(), + conversationId: DEMO_BOT_ID, + senderId: 'me', + kind: 'text', + text, + payload: {}, + createdAt: new Date().toISOString(), + } + setMessages((m) => [...m, mine]) + setDraft('') + setBotTyping(true) + window.setTimeout( + () => { + void (async () => { + const wantsSong = /song|music|play|listen|share/i.test(text) + const reply: ChatMessage | null = wantsSong + ? await _demoSong() + : null + setBotTyping(false) + setMessages((m) => [ + ...m, + reply ?? { + id: _demoId(), + conversationId: DEMO_BOT_ID, + senderId: DEMO_BOT_ID, + kind: 'text', + text: DEMO_REPLIES[m.length % DEMO_REPLIES.length], + payload: {}, + createdAt: new Date().toISOString(), + }, + ]) + })() + }, + 900 + Math.random() * 700, + ) + } + + const openDemoCard = async (trackId: string, lyrics: boolean) => { + try { + const t = await tracksApi.getTrack(trackId) + if (!t) throw new Error('not found') + playTrack(t) + if (lyrics) navigate('/full-player') + } catch { + /* demo card with an unresolvable track — nothing to do */ + } + } + + return ( + <> +
+ + R +
+

Rheo

+

demo companion — replies stay on this device

+
+
+ +
+ ({ msg: m, showMeta: true }))} + meId='me' + botTyping={botTyping} + onPlay={(tid) => void openDemoCard(tid, false)} + onOpenLyrics={(tid) => void openDemoCard(tid, true)} + /> +
+
+ +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } + }} + placeholder='Say something to Rheo…' + aria-label='Message text' + className='flex-1 h-11 px-4 rounded-2xl bg-[var(--bg-elevated)] + text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] + outline-none focus:ring-2 ring-[var(--accent)]' + /> + +
+ + ) +} + +function DemoBotBubbles({ messages, meId, botTyping, onPlay, onOpenLyrics }: { + messages: { msg: ChatMessage; showMeta: boolean }[] + meId: string + botTyping: boolean + onPlay: (trackId: string) => void + onOpenLyrics: (trackId: string) => void +}) { + return ( + <> + {messages.map(({ msg, showMeta }) => { + const mine = msg.senderId === meId + return ( +
+ + {msg.kind === 'text' ? ( +
+ {msg.text} +
+ ) : ( + + )} + {showMeta && msg.createdAt && ( +

+ {formatDay(msg.createdAt)} +

+ )} +
+
+ ) + })} + {botTyping && ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ )} + + ) +} + +function DemoShareCard({ msg, mine, onPlay, onOpenLyrics }: { + msg: ChatMessage + mine: boolean + onPlay: (trackId: string) => void + onOpenLyrics: (trackId: string) => void +}) { + const p = sharePayload(msg) + return ( +
+ +
+ ) +} + // ── Conversation list ───────────────────────────────────────── function ConversationList({ onOpen }: { onOpen: (peerId: string) => void }) { @@ -191,6 +497,8 @@ function ConversationList({ onOpen }: { onOpen: (peerId: string) => void }) { onOpen(peerId) } + const openDemo = () => onOpen(DEMO_BOT_ID) + return (
{/* Header */} @@ -253,6 +561,21 @@ function ConversationList({ onOpen }: { onOpen: (peerId: string) => void }) { )}
+ {/* Demo companion — always available, even without a database */} + + {/* Threads */} {isLoading ? (
@@ -276,9 +599,9 @@ function ConversationList({ onOpen }: { onOpen: (peerId: string) => void }) { Find a friend above, or share a song from its menu.

- ) : ( + ) : conversations && conversations.length > 0 ? (
- {conversations!.map((c) => ( + {conversations.map((c) => ( void }) { /> ))}
- )} + ) : null}
) } @@ -584,7 +907,6 @@ function ShareCard({ onOpen: () => void }) { const p = sharePayload(msg) - const artwork = p.artworkUrl || '/assets/logo.png' const isLyrics = msg.kind === 'lyrics' const isLinkable = msg.kind === 'playlist' || msg.kind === 'album' || msg.kind === 'artist' || msg.kind === 'blend' @@ -600,8 +922,7 @@ function ShareCard({ onClick={isLinkable ? onOpen : onPlay} className='flex items-center gap-3 p-2.5 w-full text-left hover:bg-white/5 transition-colors'>
- { (e.target as HTMLImageElement).src = '/assets/logo.png' }} /> + {!isLinkable && ( diff --git a/web/src/pages/nowplaying/NowPlaying.tsx b/web/src/pages/nowplaying/NowPlaying.tsx index a52b521..9ae4338 100644 --- a/web/src/pages/nowplaying/NowPlaying.tsx +++ b/web/src/pages/nowplaying/NowPlaying.tsx @@ -29,10 +29,13 @@ import { usePlayerStore } from "@/store/player.store"; import { useUIStore } from "@/store/ui.store"; import { useQueueStore } from "@/store/queue.store"; import { useShareStore } from "@/store/share.store"; +import { useToast } from "@/components/ui/Toaster"; +import { usePersisted } from "@/hooks/persisted.hook"; import { useAuthStore } from "@/store/auth.store"; import { useQueue } from "@/hooks/queue.hook"; import { usePlayer } from "@/hooks/player.hook"; import { useLyrics } from "@/hooks/lyrics.hook"; +import { lyricsApi } from "@/api/lyrics.api"; import { useTrackContextMenu } from "@/hooks/useTrackContextMenu"; import { tracksApi } from "@/api/tracks.api"; import { invalidateLikeSurfaces } from "@/lib/queryInvalidation"; @@ -55,7 +58,7 @@ type Tab = "queue" | "lyric" | "creator"; // ── Context menu ────────────────────────────────────────────── const MENU_ITEMS = [ - { icon: Heart, label: "Favourite", action: "like" }, + { icon: Heart, label: "Save", action: "like" }, { icon: DownloadSimple, label: "Download", action: "download" }, { icon: Plus, label: "Add to queue", action: "queue-add" }, { icon: ShareNetwork, label: "Share to chat", action: "share-chat" }, @@ -78,7 +81,7 @@ function ContextSheet({ item.action === "like" ? { ...item, - label: liked ? "Remove from favourites" : "Favourite", + label: liked ? "Removed from saved" : "Save", icon: Heart } : item @@ -177,28 +180,41 @@ function LyricsTab({ onSeek?: (seconds: number) => void; track: Track | null; }) { - const openShare = useShareStore((s) => s.openShare); + const { toast } = useToast(); const shareLyrics = () => { if (!track) return; - openShare({ - kind: "lyrics", - id: track.id, - title: track.title, - subtitle: track.artist?.name, - artworkUrl: track.artworkUrl, - snippet: lines.slice(0, 4).map((l) => l.text).join("\n"), - }); + // Lyrics must be shareable everywhere — the share-to-chat modal only + // lists real peers, which is empty on a fresh instance. Native share + // (mobile) or the clipboard (desktop) always works. + const text = lines.map((l) => l.text).filter(Boolean).join("\n"); + const payload = { + title: `${track.title} — ${track.artist?.name ?? "lyrics"}`, + text: `${text}\n\n— via Rheoson`, + }; + if (typeof navigator.share === "function") { + navigator.share(payload).catch(() => { /* user dismissed */ }); + return; + } + void navigator.clipboard + .writeText(payload.text) + .then(() => toast("Lyrics copied to clipboard", "success", 2200)) + .catch(() => toast("Could not copy lyrics", "error", 3000)); }; - const [follow, setFollow] = useState(true); + // Auto-scroll is always on for synced lyrics — a toggle the user must + // find and understand adds friction for something that should just work. + // The practical control here is text size, persisted per device. + const [lyricSize, setLyricSize] = usePersisted<'sm' | 'md' | 'lg'>( + 'lyrics-size', 'md' + ); const lineRefs = useRef<(HTMLParagraphElement | null)[]>([]); // Auto-scroll so the singing line stays centered while playing useEffect(() => { - if (!follow || !synced || !isPlaying) return; + if (!synced || !isPlaying) return; const el = lineRefs.current[activeLine]; el?.scrollIntoView({ block: "center", behavior: "smooth" }); - }, [activeLine, follow, synced, isPlaying]); + }, [activeLine, synced, isPlaying]); if (isLoading) { return ( @@ -237,17 +253,33 @@ function LyricsTab({ Static lyrics ) : ( - + + Auto-scroll on + )} +
+ {([['sm', 'A'], ['md', 'A'], ['lg', 'A']] as const).map(([size, label], i) => ( + + ))} +
{track && (
@@ -212,11 +212,11 @@ export default function StatsSection() {
)} - {/* Favourite artists */} + {/* Saved artists */} {taste && taste.top_artists.length > 0 && (

- Favourite artists + Saved artists

{taste.top_artists.slice(0, 8).map(a => ( @@ -277,7 +277,7 @@ export default function StatsSection() {
- + diff --git a/web/src/pages/stats/ListeningStats.tsx b/web/src/pages/stats/ListeningStats.tsx index 39ce8c7..783a805 100644 --- a/web/src/pages/stats/ListeningStats.tsx +++ b/web/src/pages/stats/ListeningStats.tsx @@ -185,7 +185,7 @@ export default function ListeningStats() {
- + diff --git a/web/src/types/api-generated.ts b/web/src/types/api-generated.ts index 324fd41..cd39d4d 100644 --- a/web/src/types/api-generated.ts +++ b/web/src/types/api-generated.ts @@ -2278,6 +2278,11 @@ export interface components { * @default */ description: string; + /** + * Trackids + * @default [] + */ + trackIds: string[]; }; /** DirectoriesSchema */ DirectoriesSchema: { diff --git a/web/src/types/openapi.json b/web/src/types/openapi.json index 33a1053..90dac51 100644 --- a/web/src/types/openapi.json +++ b/web/src/types/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Rheoson API", - "version": "2.22.2" + "version": "2.22.3" }, "paths": { "/api/analytics/listening-by-day": { @@ -5334,6 +5334,14 @@ "type": "string", "title": "Description", "default": "" + }, + "trackIds": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Trackids", + "default": [] } }, "type": "object",