Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add TypeScript SDK and playlists UI

Add a full TypeScript SDK under sdk/typescript (client, transports,
APIs, types, errors, plugin registry), with package/bun config,
lockfile, README, CLAUDE and tests. Add web UI playlist pages and
components (Playlists, PlaylistDetails, modals, styles), GraphQL
queries/mutations and generated hooks. Fix GraphQL schema imports to
include track types for saved and smart playlists.

+4970 -19
+21 -1
crates/graphql/src/schema/saved_playlist.rs
··· 1 1 use async_graphql::*; 2 + use rockbox_library::entity::track::Track as LibraryTrack; 2 3 use rockbox_playlists::{Playlist, PlaylistFolder}; 3 4 4 5 use crate::{ 5 6 rockbox_url, 6 - schema::objects::saved_playlist::{SavedPlaylist, SavedPlaylistFolder}, 7 + schema::objects::{ 8 + saved_playlist::{SavedPlaylist, SavedPlaylistFolder}, 9 + track::Track, 10 + }, 7 11 }; 8 12 9 13 #[derive(Default)] ··· 58 62 playlist_id 59 63 ); 60 64 Ok(client.get(&url).send().await?.json::<Vec<String>>().await?) 65 + } 66 + 67 + async fn saved_playlist_tracks( 68 + &self, 69 + ctx: &Context<'_>, 70 + playlist_id: String, 71 + ) -> Result<Vec<Track>, Error> { 72 + let client = ctx.data::<reqwest::Client>()?; 73 + let url = format!("{}/saved-playlists/{}/tracks", rockbox_url(), playlist_id); 74 + let tracks = client 75 + .get(&url) 76 + .send() 77 + .await? 78 + .json::<Vec<LibraryTrack>>() 79 + .await?; 80 + Ok(tracks.into_iter().map(Track::from).collect()) 61 81 } 62 82 63 83 async fn playlist_folders(&self, ctx: &Context<'_>) -> Result<Vec<SavedPlaylistFolder>, Error> {
+21 -1
crates/graphql/src/schema/smart_playlist.rs
··· 1 1 use async_graphql::*; 2 + use rockbox_library::entity::track::Track as LibraryTrack; 2 3 use rockbox_playlists::SmartPlaylist as RsSmartPlaylist; 3 4 4 5 use crate::{ 5 6 rockbox_url, 6 - schema::objects::smart_playlist::{SmartPlaylist, TrackStats}, 7 + schema::objects::{ 8 + smart_playlist::{SmartPlaylist, TrackStats}, 9 + track::Track, 10 + }, 7 11 }; 8 12 9 13 #[derive(Default)] ··· 56 60 .into_iter() 57 61 .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) 58 62 .collect()) 63 + } 64 + 65 + async fn smart_playlist_tracks( 66 + &self, 67 + ctx: &Context<'_>, 68 + id: String, 69 + ) -> Result<Vec<Track>, Error> { 70 + let client = ctx.data::<reqwest::Client>()?; 71 + let url = format!("{}/smart-playlists/{}/tracks", rockbox_url(), id); 72 + let tracks = client 73 + .get(&url) 74 + .send() 75 + .await? 76 + .json::<Vec<LibraryTrack>>() 77 + .await?; 78 + Ok(tracks.into_iter().map(Track::from).collect()) 59 79 } 60 80 61 81 async fn track_stats(
+34
sdk/typescript/.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+106
sdk/typescript/CLAUDE.md
··· 1 + 2 + Default to using Bun instead of Node.js. 3 + 4 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 5 + - Use `bun test` instead of `jest` or `vitest` 6 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 7 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 8 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 9 + - Use `bunx <package> <command>` instead of `npx <package> <command>` 10 + - Bun automatically loads .env, so don't use dotenv. 11 + 12 + ## APIs 13 + 14 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 15 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 16 + - `Bun.redis` for Redis. Don't use `ioredis`. 17 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 18 + - `WebSocket` is built-in. Don't use `ws`. 19 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 20 + - Bun.$`ls` instead of execa. 21 + 22 + ## Testing 23 + 24 + Use `bun test` to run tests. 25 + 26 + ```ts#index.test.ts 27 + import { test, expect } from "bun:test"; 28 + 29 + test("hello world", () => { 30 + expect(1).toBe(1); 31 + }); 32 + ``` 33 + 34 + ## Frontend 35 + 36 + Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 37 + 38 + Server: 39 + 40 + ```ts#index.ts 41 + import index from "./index.html" 42 + 43 + Bun.serve({ 44 + routes: { 45 + "/": index, 46 + "/api/users/:id": { 47 + GET: (req) => { 48 + return new Response(JSON.stringify({ id: req.params.id })); 49 + }, 50 + }, 51 + }, 52 + // optional websocket support 53 + websocket: { 54 + open: (ws) => { 55 + ws.send("Hello, world!"); 56 + }, 57 + message: (ws, message) => { 58 + ws.send(message); 59 + }, 60 + close: (ws) => { 61 + // handle close 62 + } 63 + }, 64 + development: { 65 + hmr: true, 66 + console: true, 67 + } 68 + }) 69 + ``` 70 + 71 + HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 72 + 73 + ```html#index.html 74 + <html> 75 + <body> 76 + <h1>Hello, world!</h1> 77 + <script type="module" src="./frontend.tsx"></script> 78 + </body> 79 + </html> 80 + ``` 81 + 82 + With the following `frontend.tsx`: 83 + 84 + ```tsx#frontend.tsx 85 + import React from "react"; 86 + import { createRoot } from "react-dom/client"; 87 + 88 + // import .css files directly and it works 89 + import './index.css'; 90 + 91 + const root = createRoot(document.body); 92 + 93 + export default function Frontend() { 94 + return <h1>Hello, world!</h1>; 95 + } 96 + 97 + root.render(<Frontend />); 98 + ``` 99 + 100 + Then, run index.ts 101 + 102 + ```sh 103 + bun --hot ./index.ts 104 + ``` 105 + 106 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+959
sdk/typescript/README.md
··· 1 + # @rockbox-zig/sdk 2 + 3 + TypeScript SDK for [Rockbox](https://www.rockbox.org) — a fully typed GraphQL client with real-time subscriptions and a plugin system. 4 + 5 + ## Table of contents 6 + 7 + - [Installation](#installation) 8 + - [Quick start](#quick-start) 9 + - [Configuration](#configuration) 10 + - [API reference](#api-reference) 11 + - [Playback](#playback) 12 + - [Library](#library) 13 + - [Playlist (queue)](#playlist-queue) 14 + - [Saved playlists](#saved-playlists) 15 + - [Smart playlists](#smart-playlists) 16 + - [Sound](#sound) 17 + - [Settings](#settings) 18 + - [System](#system) 19 + - [Browse (filesystem)](#browse-filesystem) 20 + - [Devices](#devices) 21 + - [Real-time events](#real-time-events) 22 + - [Plugin system](#plugin-system) 23 + - [Error handling](#error-handling) 24 + - [Raw GraphQL queries](#raw-graphql-queries) 25 + - [Types reference](#types-reference) 26 + 27 + --- 28 + 29 + ## Installation 30 + 31 + ```sh 32 + bun add @rockbox-zig/sdk 33 + # or 34 + npm install @rockbox-zig/sdk 35 + ``` 36 + 37 + `rockboxd` must be running and reachable. By default the SDK connects to `http://localhost:6062/graphql`. Start rockboxd with: 38 + 39 + ```sh 40 + ./zig/zig-out/bin/rockboxd 41 + ``` 42 + 43 + --- 44 + 45 + ## Quick start 46 + 47 + ```typescript 48 + import { RockboxClient, PlaybackStatus } from '@rockbox-zig/sdk'; 49 + 50 + const client = new RockboxClient(); 51 + 52 + // Optional: start WebSocket subscriptions for real-time events 53 + client.connect(); 54 + 55 + // Check what is playing 56 + const track = await client.playback.currentTrack(); 57 + if (track) { 58 + console.log(`Now playing: ${track.title} — ${track.artist}`); 59 + } 60 + 61 + // Search the library 62 + const { albums, tracks } = await client.library.search('dark side'); 63 + console.log(`Found ${albums.length} albums and ${tracks.length} tracks`); 64 + 65 + // Play an album with shuffle 66 + await client.playback.playAlbum(albums[0]!.id, { shuffle: true }); 67 + 68 + // React to track changes 69 + client.on('track:changed', (track) => { 70 + console.log(`▶ ${track.title} by ${track.artist}`); 71 + }); 72 + 73 + // Tear down when done 74 + client.disconnect(); 75 + ``` 76 + 77 + --- 78 + 79 + ## Configuration 80 + 81 + ```typescript 82 + import { RockboxClient } from '@rockbox-zig/sdk'; 83 + 84 + // Defaults: localhost:6062 85 + const client = new RockboxClient(); 86 + 87 + // Custom host and port 88 + const client = new RockboxClient({ host: '192.168.1.42', port: 6062 }); 89 + 90 + // Fully custom URLs (useful behind a reverse proxy) 91 + const client = new RockboxClient({ 92 + httpUrl: 'https://music.home/graphql', 93 + wsUrl: 'wss://music.home/graphql', 94 + }); 95 + ``` 96 + 97 + | Option | Type | Default | Description | 98 + |-----------|----------|--------------------------------|-----------------------------------------------------| 99 + | `host` | `string` | `"localhost"` | Hostname or IP of rockboxd | 100 + | `port` | `number` | `6062` | GraphQL port (env: `ROCKBOX_GRAPHQL_PORT`) | 101 + | `httpUrl` | `string` | `http://{host}:{port}/graphql` | Override the full HTTP URL | 102 + | `wsUrl` | `string` | `ws://{host}:{port}/graphql` | Override the full WebSocket URL | 103 + 104 + --- 105 + 106 + ## API reference 107 + 108 + ### Playback 109 + 110 + ```typescript 111 + client.playback 112 + ``` 113 + 114 + #### Transport controls 115 + 116 + ```typescript 117 + // Check status 118 + const status = await client.playback.status(); 119 + // Returns a PlaybackStatus enum value 120 + // PlaybackStatus.Stopped = 0, PlaybackStatus.Playing = 1, PlaybackStatus.Paused = 2 121 + 122 + import { PlaybackStatus } from '@rockbox-zig/sdk'; 123 + if (status === PlaybackStatus.Playing) { 124 + await client.playback.pause(); 125 + } else { 126 + await client.playback.resume(); 127 + } 128 + 129 + await client.playback.next(); 130 + await client.playback.previous(); 131 + await client.playback.stop(); 132 + 133 + // Seek to an absolute position (milliseconds) 134 + await client.playback.seek(90_000); // jump to 1:30 135 + 136 + // Get current file byte offset 137 + const pos = await client.playback.filePosition(); 138 + ``` 139 + 140 + #### Current and next track 141 + 142 + ```typescript 143 + const track = await client.playback.currentTrack(); 144 + // null when nothing is playing 145 + if (track) { 146 + const pct = ((track.elapsed / track.length) * 100).toFixed(0); 147 + console.log(`${track.title} — ${pct}% (${track.bitrate} kbps)`); 148 + } 149 + 150 + const next = await client.playback.nextTrack(); 151 + ``` 152 + 153 + #### Play helpers 154 + 155 + Single-call shortcuts to load content and start playing immediately: 156 + 157 + ```typescript 158 + // Play a single file by path 159 + await client.playback.playTrack('/Music/Pink Floyd/Wish You Were Here.mp3'); 160 + 161 + // Play all tracks from an album 162 + await client.playback.playAlbum('album-id'); 163 + await client.playback.playAlbum('album-id', { shuffle: true }); 164 + await client.playback.playAlbum('album-id', { position: 3 }); // start at track index 3 165 + 166 + // Play all tracks from an artist 167 + await client.playback.playArtist('artist-id', { shuffle: true }); 168 + 169 + // Play a saved playlist 170 + await client.playback.playPlaylist('playlist-id', { shuffle: true }); 171 + 172 + // Play all files under a directory (recursively, shuffled) 173 + await client.playback.playDirectory('/Music/Jazz', { recurse: true, shuffle: true }); 174 + 175 + // Play the liked tracks collection 176 + await client.playback.playLikedTracks({ shuffle: true }); 177 + 178 + // Play the entire library, shuffled 179 + await client.playback.playAllTracks({ shuffle: true }); 180 + 181 + // Force-reload the current queue from disk 182 + await client.playback.flushAndReload(); 183 + ``` 184 + 185 + --- 186 + 187 + ### Library 188 + 189 + ```typescript 190 + client.library 191 + ``` 192 + 193 + #### Albums 194 + 195 + ```typescript 196 + // All albums — tracks array contains shallow track stubs 197 + const albums = await client.library.albums(); 198 + albums.forEach((a) => console.log(`${a.title} (${a.year}) — ${a.artist}`)); 199 + 200 + // Single album with full track list 201 + const album = await client.library.album('album-id'); 202 + if (album) { 203 + console.log(`${album.title} — ${album.tracks.length} tracks`); 204 + album.tracks.forEach((t) => console.log(` ${t.tracknum}. ${t.title}`)); 205 + } 206 + 207 + // Liked albums 208 + const liked = await client.library.likedAlbums(); 209 + 210 + // Like / unlike 211 + await client.library.likeAlbum('album-id'); 212 + await client.library.unlikeAlbum('album-id'); 213 + ``` 214 + 215 + #### Artists 216 + 217 + ```typescript 218 + const artists = await client.library.artists(); 219 + artists.forEach((a) => console.log(`${a.name} — ${a.albums.length} albums`)); 220 + 221 + // Single artist with albums and tracks 222 + const artist = await client.library.artist('artist-id'); 223 + if (artist) { 224 + console.log(`${artist.name}`); 225 + artist.albums.forEach((a) => console.log(` • ${a.title} (${a.year})`)); 226 + } 227 + ``` 228 + 229 + #### Tracks 230 + 231 + ```typescript 232 + const tracks = await client.library.tracks(); 233 + const track = await client.library.track('track-id'); 234 + const liked = await client.library.likedTracks(); 235 + 236 + await client.library.likeTrack('track-id'); 237 + await client.library.unlikeTrack('track-id'); 238 + ``` 239 + 240 + #### Search 241 + 242 + ```typescript 243 + const results = await client.library.search('radiohead'); 244 + // results: { artists, albums, tracks, likedTracks, likedAlbums } 245 + 246 + console.log(`Artists : ${results.artists.map((a) => a.name).join(', ')}`); 247 + console.log(`Albums : ${results.albums.map((a) => a.title).join(', ')}`); 248 + console.log(`Tracks : ${results.tracks.length} match(es)`); 249 + ``` 250 + 251 + #### Library scan 252 + 253 + ```typescript 254 + // Trigger a full rescan of music_dir 255 + await client.library.scan(); 256 + ``` 257 + 258 + --- 259 + 260 + ### Playlist (queue) 261 + 262 + ```typescript 263 + client.playlist 264 + ``` 265 + 266 + The *playlist* API manages the live playback queue — what is playing right now. For persistent named collections see [Saved playlists](#saved-playlists). 267 + 268 + ```typescript 269 + // Inspect the current queue 270 + const queue = await client.playlist.current(); 271 + console.log(`${queue.amount} tracks, at index ${queue.index}`); 272 + 273 + queue.tracks.forEach((t, i) => { 274 + const active = i === queue.index ? '▶' : ' '; 275 + console.log(`${active} ${i + 1}. ${t.title} — ${t.artist}`); 276 + }); 277 + 278 + const count = await client.playlist.amount(); 279 + ``` 280 + 281 + #### Queue management 282 + 283 + ```typescript 284 + import { InsertPosition } from '@rockbox-zig/sdk'; 285 + 286 + // Insert right after the current track 287 + await client.playlist.insertTracks( 288 + ['/Music/track1.mp3', '/Music/track2.mp3'], 289 + InsertPosition.Next, 290 + ); 291 + 292 + // Append at the end 293 + await client.playlist.insertTracks(paths, InsertPosition.Last); 294 + 295 + // Replace the whole queue 296 + await client.playlist.insertTracks(paths, InsertPosition.First); 297 + 298 + // Insert a whole directory 299 + await client.playlist.insertDirectory('/Music/Ambient', InsertPosition.Last); 300 + 301 + // Insert an album by library ID 302 + await client.playlist.insertAlbum('album-id', InsertPosition.Next); 303 + 304 + // Remove track at queue index 2 (0-based) 305 + await client.playlist.removeTrack(2); 306 + 307 + // Clear the entire queue 308 + await client.playlist.clear(); 309 + 310 + // Shuffle remaining tracks 311 + await client.playlist.shuffle(); 312 + 313 + // Create a new queue and start playing immediately 314 + await client.playlist.create('Evening Mix', [ 315 + '/Music/track1.mp3', 316 + '/Music/track2.mp3', 317 + '/Music/track3.mp3', 318 + ]); 319 + 320 + // Resume from where playback was stopped 321 + await client.playlist.resume(); 322 + ``` 323 + 324 + | `InsertPosition` | Value | Effect | 325 + |------------------|-------|------------------------------------------| 326 + | `Next` | `0` | After the currently playing track | 327 + | `AfterCurrent` | `1` | After the last manually inserted track | 328 + | `Last` | `2` | At the end of the queue | 329 + | `First` | `3` | Replace the entire queue | 330 + 331 + --- 332 + 333 + ### Saved playlists 334 + 335 + ```typescript 336 + client.savedPlaylists 337 + ``` 338 + 339 + Saved playlists are persistent named collections stored in the database. 340 + 341 + ```typescript 342 + // List all playlists, optionally filtered by folder 343 + const playlists = await client.savedPlaylists.list(); 344 + const inFolder = await client.savedPlaylists.list('folder-id'); 345 + 346 + // Get a single playlist 347 + const pl = await client.savedPlaylists.get('playlist-id'); 348 + if (pl) console.log(`${pl.name} — ${pl.trackCount} tracks`); 349 + 350 + // Get ordered track IDs 351 + const trackIds = await client.savedPlaylists.trackIds('playlist-id'); 352 + 353 + // Create 354 + const newPl = await client.savedPlaylists.create({ 355 + name: 'Late Night Jazz', 356 + description: 'Quiet music for working', 357 + folderId: 'folder-id', // optional 358 + trackIds: ['t1', 't2', 't3'], // optional seed tracks 359 + }); 360 + 361 + // Update metadata 362 + await client.savedPlaylists.update('playlist-id', { 363 + name: 'Late Night Jazz (updated)', 364 + description: 'Still quiet', 365 + }); 366 + 367 + // Add / remove individual tracks 368 + await client.savedPlaylists.addTracks('playlist-id', ['t4', 't5']); 369 + await client.savedPlaylists.removeTrack('playlist-id', 't1'); 370 + 371 + // Load into queue and play 372 + await client.savedPlaylists.play('playlist-id'); 373 + 374 + // Delete permanently 375 + await client.savedPlaylists.delete('playlist-id'); 376 + ``` 377 + 378 + #### Folders 379 + 380 + ```typescript 381 + const folders = await client.savedPlaylists.folders(); 382 + folders.forEach((f) => console.log(f.name)); 383 + 384 + const folder = await client.savedPlaylists.createFolder('Work'); 385 + console.log(`Created folder: ${folder.id}`); 386 + 387 + await client.savedPlaylists.deleteFolder(folder.id); 388 + ``` 389 + 390 + --- 391 + 392 + ### Smart playlists 393 + 394 + ```typescript 395 + client.smartPlaylists 396 + ``` 397 + 398 + Smart playlists evaluate a rule set dynamically each time they are played. 399 + 400 + ```typescript 401 + // List and get 402 + const smarts = await client.smartPlaylists.list(); 403 + const sp = await client.smartPlaylists.get('smart-id'); 404 + 405 + // Resolve the matching track IDs right now 406 + const ids = await client.smartPlaylists.trackIds('smart-id'); 407 + console.log(`Smart playlist resolves to ${ids.length} tracks`); 408 + 409 + // Create — rules is a JSON-encoded rule set 410 + const recentlyPlayed = await client.smartPlaylists.create({ 411 + name: 'Recently played', 412 + rules: JSON.stringify({ 413 + operator: 'AND', 414 + rules: [ 415 + { field: 'play_count', op: 'gt', value: 0 }, 416 + { field: 'last_played', op: 'within', value: '30d' }, 417 + ], 418 + }), 419 + }); 420 + 421 + // Create a "top rated" smart playlist 422 + const topRated = await client.smartPlaylists.create({ 423 + name: 'Most played', 424 + rules: JSON.stringify({ 425 + operator: 'AND', 426 + rules: [{ field: 'play_count', op: 'gte', value: 10 }], 427 + sort: { field: 'play_count', dir: 'desc' }, 428 + limit: 50, 429 + }), 430 + }); 431 + 432 + // Update, play, delete 433 + await client.smartPlaylists.update('smart-id', { name: 'Recently played (60d)', rules: '...' }); 434 + await client.smartPlaylists.play('smart-id'); 435 + await client.smartPlaylists.delete('smart-id'); 436 + ``` 437 + 438 + #### Listening stats 439 + 440 + Stats power smart playlist rules and are also useful for scrobbling integrations. 441 + 442 + ```typescript 443 + const stats = await client.smartPlaylists.trackStats('track-id'); 444 + if (stats) { 445 + console.log(`Played: ${stats.playCount}, skipped: ${stats.skipCount}`); 446 + if (stats.lastPlayed) { 447 + console.log(`Last played: ${new Date(stats.lastPlayed * 1000).toLocaleDateString()}`); 448 + } 449 + } 450 + 451 + // Record events manually (e.g. from a scrobbler plugin) 452 + await client.smartPlaylists.recordPlayed('track-id'); 453 + await client.smartPlaylists.recordSkipped('track-id'); 454 + ``` 455 + 456 + --- 457 + 458 + ### Sound 459 + 460 + ```typescript 461 + client.sound 462 + ``` 463 + 464 + Volume is adjusted in firmware-defined steps (not absolute dB values). The number of steps per dB varies by hardware target. 465 + 466 + ```typescript 467 + // Relative adjustment: positive = louder, negative = quieter 468 + const newRaw = await client.sound.adjustVolume(+3); // 3 steps up 469 + await client.sound.adjustVolume(-1); // 1 step down 470 + 471 + // Convenience one-step helpers 472 + await client.sound.volumeUp(); // equivalent to adjustVolume(+1) 473 + await client.sound.volumeDown(); // equivalent to adjustVolume(-1) 474 + ``` 475 + 476 + --- 477 + 478 + ### Settings 479 + 480 + ```typescript 481 + client.settings 482 + ``` 483 + 484 + ```typescript 485 + // Read all global settings 486 + const s = await client.settings.get(); 487 + console.log(`Music directory : ${s.musicDir}`); 488 + console.log(`Volume : ${s.volume}`); 489 + console.log(`EQ enabled : ${s.eqEnabled}`); 490 + console.log(`Repeat mode : ${s.repeatMode}`); 491 + 492 + // Partial update — only the fields you pass are written 493 + import { RepeatMode } from '@rockbox-zig/sdk'; 494 + 495 + await client.settings.save({ 496 + shuffle: true, 497 + repeatMode: RepeatMode.All, 498 + }); 499 + 500 + // Enable and configure the equalizer 501 + await client.settings.save({ 502 + eqEnabled: true, 503 + eqPrecut: -3, 504 + eqBandSettings: [ 505 + { cutoff: 60, q: 7, gain: 3 }, // bass boost 506 + { cutoff: 200, q: 7, gain: 0 }, 507 + { cutoff: 800, q: 7, gain: 0 }, 508 + { cutoff: 4000, q: 7, gain: -2 }, // presence cut 509 + { cutoff: 12000, q: 7, gain: 1 }, 510 + ], 511 + }); 512 + 513 + // Configure dynamics compression 514 + await client.settings.save({ 515 + compressorSettings: { 516 + threshold: -24, 517 + makeupGain: 3, 518 + ratio: 2, // 2:1 519 + knee: 0, 520 + releaseTime: 100, 521 + attackTime: 5, 522 + }, 523 + }); 524 + 525 + // Replaygain 526 + import { ReplaygainType } from '@rockbox-zig/sdk'; 527 + await client.settings.save({ 528 + replaygainSettings: { 529 + noclip: true, 530 + type: ReplaygainType.Album, 531 + preamp: 0, 532 + }, 533 + }); 534 + ``` 535 + 536 + --- 537 + 538 + ### System 539 + 540 + ```typescript 541 + client.system 542 + ``` 543 + 544 + ```typescript 545 + const version = await client.system.version(); 546 + console.log(`rockboxd ${version}`); 547 + 548 + const status = await client.system.status(); 549 + console.log(`Runtime : ${status.runtime}s`); 550 + console.log(`Top run : ${status.topruntime}s`); 551 + console.log(`Resume at: track ${status.resumeIndex}`); 552 + ``` 553 + 554 + --- 555 + 556 + ### Browse (filesystem) 557 + 558 + ```typescript 559 + client.browse 560 + ``` 561 + 562 + Walk the filesystem relative to the configured `music_dir`. 563 + 564 + ```typescript 565 + import { isDirectory } from '@rockbox-zig/sdk'; 566 + 567 + // Root of music_dir 568 + const entries = await client.browse.entries(); 569 + 570 + // Specific path 571 + const entries = await client.browse.entries('/Music/Pink Floyd'); 572 + entries.forEach((e) => { 573 + const icon = isDirectory(e) ? '📁' : '🎵'; 574 + console.log(`${icon} ${e.name}`); 575 + }); 576 + 577 + // Only directories 578 + const dirs = await client.browse.directories('/Music'); 579 + dirs.forEach((d) => console.log(d.name)); 580 + 581 + // Only files 582 + const files = await client.browse.files('/Music/Pink Floyd/The Wall'); 583 + files.forEach((f) => console.log(f.name)); 584 + ``` 585 + 586 + --- 587 + 588 + ### Devices 589 + 590 + ```typescript 591 + client.devices 592 + ``` 593 + 594 + Devices are remote output sinks (Chromecast, AirPlay receivers, etc.) discovered via mDNS. 595 + 596 + ```typescript 597 + // List all discovered devices 598 + const devices = await client.devices.list(); 599 + devices.forEach((d) => { 600 + const status = d.isConnected ? '● connected' : '○ available'; 601 + const type = d.isCastDevice ? 'Cast' : d.isSourceDevice ? 'Source' : 'Other'; 602 + console.log(`[${type}] ${d.name} (${d.ip}:${d.port}) — ${status}`); 603 + }); 604 + 605 + // Get a specific device 606 + const device = await client.devices.get('device-id'); 607 + if (device?.isCastDevice) { 608 + console.log(`Casting to: ${device.name}`); 609 + } 610 + 611 + // Connect — switches the active PCM output sink to this device 612 + await client.devices.connect('chromecast-device-id'); 613 + 614 + // Disconnect — reverts to the built-in PCM sink 615 + await client.devices.disconnect('chromecast-device-id'); 616 + ``` 617 + 618 + --- 619 + 620 + ## Real-time events 621 + 622 + Call `client.connect()` to open the WebSocket. The connection is lazy (only created on first call), auto-reconnecting with exponential backoff up to 30 seconds. 623 + 624 + ```typescript 625 + import { RockboxClient, PlaybackStatus, type Track } from '@rockbox-zig/sdk'; 626 + 627 + const client = new RockboxClient(); 628 + client.connect(); 629 + 630 + // ── Track changes ────────────────────────────────────────────────────────── 631 + client.on('track:changed', (track: Track) => { 632 + document.title = `${track.title} — ${track.artist}`; 633 + updateNowPlayingUI(track); 634 + }); 635 + 636 + // ── Playback status ──────────────────────────────────────────────────────── 637 + client.on('status:changed', (raw) => { 638 + const label: Record<number, string> = { 639 + [PlaybackStatus.Stopped]: 'Stopped', 640 + [PlaybackStatus.Playing]: 'Playing', 641 + [PlaybackStatus.Paused]: 'Paused', 642 + }; 643 + setStatusBadge(label[raw] ?? 'Unknown'); 644 + }); 645 + 646 + // ── Queue changes ────────────────────────────────────────────────────────── 647 + client.on('playlist:changed', (queue) => { 648 + console.log(`Queue updated — ${queue.amount} tracks`); 649 + renderQueue(queue.tracks); 650 + }); 651 + 652 + // ── WebSocket lifecycle ──────────────────────────────────────────────────── 653 + client.on('ws:error', (err) => console.error('WebSocket error:', err.message)); 654 + 655 + // ── One-time listener ────────────────────────────────────────────────────── 656 + client.once('track:changed', (track) => { 657 + console.log('First track event received:', track.title); 658 + }); 659 + 660 + // ── Remove a listener ────────────────────────────────────────────────────── 661 + const handler = (track: Track) => console.log(track.title); 662 + client.on('track:changed', handler); 663 + // ...later: 664 + client.off('track:changed', handler); 665 + 666 + // ── Tear down subscriptions and close the WebSocket ──────────────────────── 667 + client.disconnect(); 668 + ``` 669 + 670 + ### Event map 671 + 672 + | Event | Payload | Description | 673 + |--------------------|------------|---------------------------------------| 674 + | `track:changed` | `Track` | Currently playing track changed | 675 + | `status:changed` | `number` | Playback status changed (0 / 1 / 2) | 676 + | `playlist:changed` | `Playlist` | Active queue was modified | 677 + | `ws:error` | `Error` | WebSocket or subscription error | 678 + | `ws:open` | — | WebSocket connection established | 679 + | `ws:close` | — | WebSocket connection closed | 680 + 681 + --- 682 + 683 + ## Plugin system 684 + 685 + Plugins are the recommended way to add cross-cutting features — scrobbling, notifications, analytics, remote control — without forking the SDK. The design is inspired by Jellyfin's `IPlugin` interface. 686 + 687 + ### Writing a plugin 688 + 689 + ```typescript 690 + import type { RockboxPlugin, PluginContext, Track } from '@rockbox-zig/sdk'; 691 + 692 + export const LastFmScrobbler: RockboxPlugin = { 693 + name: 'lastfm-scrobbler', 694 + version: '1.0.0', 695 + description: 'Scrobble played tracks to Last.fm', 696 + 697 + install({ events }: PluginContext) { 698 + let startedAt = 0; 699 + let current: Track | null = null; 700 + 701 + events.on('track:changed', (track) => { 702 + // Scrobble the previous track if it played for more than 30 s 703 + if (current && Date.now() - startedAt > 30_000) { 704 + submitScrobble({ title: current.title, artist: current.artist }); 705 + } 706 + current = track; 707 + startedAt = Date.now(); 708 + }); 709 + }, 710 + 711 + uninstall() { 712 + // release any timers or external connections here 713 + }, 714 + }; 715 + ``` 716 + 717 + ### Installing a plugin 718 + 719 + ```typescript 720 + const client = new RockboxClient(); 721 + client.connect(); 722 + 723 + await client.use(LastFmScrobbler); 724 + 725 + // List what is installed 726 + client.installedPlugins().forEach((p) => { 727 + console.log(`${p.name} v${p.version}${p.description ? ` — ${p.description}` : ''}`); 728 + }); 729 + 730 + // Uninstall cleanly 731 + await client.unuse('lastfm-scrobbler'); 732 + ``` 733 + 734 + ### Plugin with custom queries 735 + 736 + `PluginContext.query()` exposes the raw HTTP transport for one-off GraphQL operations: 737 + 738 + ```typescript 739 + const LyricsPlugin: RockboxPlugin = { 740 + name: 'lyrics', 741 + version: '0.1.0', 742 + 743 + install({ query, events }) { 744 + events.on('track:changed', async (track) => { 745 + if (!track.id) return; 746 + const data = await query<{ track: { title: string; artist: string } }>( 747 + `query T($id: String!) { track(id: $id) { title artist } }`, 748 + { id: track.id }, 749 + ); 750 + fetchAndDisplayLyrics(data.track.title, data.track.artist); 751 + }); 752 + }, 753 + }; 754 + ``` 755 + 756 + ### Example: desktop notification plugin 757 + 758 + ```typescript 759 + import type { RockboxPlugin } from '@rockbox-zig/sdk'; 760 + 761 + export const DesktopNotifications: RockboxPlugin = { 762 + name: 'desktop-notifications', 763 + version: '1.0.0', 764 + 765 + install({ events }) { 766 + if (typeof Notification === 'undefined') return; 767 + 768 + Notification.requestPermission(); 769 + 770 + events.on('track:changed', (track) => { 771 + new Notification(track.title, { 772 + body: `${track.artist} · ${track.album}`, 773 + icon: track.albumArt ?? undefined, 774 + }); 775 + }); 776 + }, 777 + }; 778 + ``` 779 + 780 + ### Example: auto-scrobble + sleep timer plugin 781 + 782 + ```typescript 783 + import type { RockboxPlugin } from '@rockbox-zig/sdk'; 784 + 785 + export function sleepTimer(minutes: number): RockboxPlugin { 786 + let timer: ReturnType<typeof setTimeout> | null = null; 787 + 788 + return { 789 + name: 'sleep-timer', 790 + version: '1.0.0', 791 + description: `Stop playback after ${minutes} minutes`, 792 + 793 + install({ events, query }) { 794 + timer = setTimeout(async () => { 795 + await query('mutation { hardStop }'); 796 + console.log('Sleep timer fired — playback stopped.'); 797 + }, minutes * 60_000); 798 + 799 + events.on('status:changed', (status) => { 800 + // Cancel the timer if the user already stopped playback manually 801 + if (status === 0 && timer) { 802 + clearTimeout(timer); 803 + timer = null; 804 + } 805 + }); 806 + }, 807 + 808 + uninstall() { 809 + if (timer) clearTimeout(timer); 810 + }, 811 + }; 812 + } 813 + 814 + // Usage 815 + await client.use(sleepTimer(30)); // stop in 30 minutes 816 + ``` 817 + 818 + --- 819 + 820 + ## Error handling 821 + 822 + All methods throw typed errors, making it easy to distinguish network failures from API errors: 823 + 824 + ```typescript 825 + import { 826 + RockboxNetworkError, 827 + RockboxGraphQLError, 828 + RockboxError, 829 + } from '@rockbox-zig/sdk'; 830 + 831 + try { 832 + await client.playback.play(); 833 + } catch (err) { 834 + if (err instanceof RockboxNetworkError) { 835 + // rockboxd is unreachable — show an "offline" indicator 836 + showOfflineBanner(err.message); 837 + } else if (err instanceof RockboxGraphQLError) { 838 + // The server returned structured GraphQL errors 839 + for (const e of err.errors) { 840 + console.error('GraphQL:', e.message, e.path); 841 + } 842 + } else if (err instanceof RockboxError) { 843 + // Base class — catches any remaining SDK error 844 + console.error('Rockbox error:', err.message); 845 + } 846 + } 847 + ``` 848 + 849 + | Class | When thrown | 850 + |------------------------|----------------------------------------------------------| 851 + | `RockboxNetworkError` | `fetch` rejects or HTTP status is not 2xx | 852 + | `RockboxGraphQLError` | Server returns `{ errors: [...] }` in the response body | 853 + | `RockboxError` | Base class — catch to handle all SDK errors | 854 + 855 + --- 856 + 857 + ## Raw GraphQL queries 858 + 859 + For operations not yet covered by the SDK use the `client.query()` escape hatch. The GraphiQL explorer is available at `http://localhost:6062/graphiql` while rockboxd is running. 860 + 861 + ```typescript 862 + // Simple query 863 + const data = await client.query<{ rockboxVersion: string }>( 864 + `query { rockboxVersion }`, 865 + ); 866 + console.log(data.rockboxVersion); 867 + 868 + // Query with variables 869 + const data = await client.query<{ album: { id: string; title: string } | null }>( 870 + `query Album($id: String!) { 871 + album(id: $id) { id title artist year } 872 + }`, 873 + { id: 'album-123' }, 874 + ); 875 + 876 + // Mutation 877 + await client.query( 878 + `mutation Seek($t: Int!) { fastForwardRewind(newTime: $t) }`, 879 + { t: 120_000 }, 880 + ); 881 + ``` 882 + 883 + --- 884 + 885 + ## Types reference 886 + 887 + All types and enums are exported from `@rockbox-zig/sdk`: 888 + 889 + ```typescript 890 + import type { 891 + Track, Album, Artist, SearchResults, 892 + Playlist, SavedPlaylist, SavedPlaylistFolder, 893 + SmartPlaylist, TrackStats, 894 + Device, Entry, 895 + UserSettings, PartialUserSettings, 896 + EqBandSetting, ReplaygainSettings, CompressorSettings, 897 + SystemStatus, 898 + } from '@rockbox-zig/sdk'; 899 + ``` 900 + 901 + ### Enums 902 + 903 + ```typescript 904 + import { 905 + PlaybackStatus, // Stopped=0 Playing=1 Paused=2 906 + RepeatMode, // Off=0 All=1 One=2 Shuffle=3 ABRepeat=4 907 + ChannelConfig, // Stereo StereoNarrow Mono LeftMix RightMix Karaoke 908 + ReplaygainType, // Track=0 Album=1 Shuffle=2 909 + InsertPosition, // Next=0 AfterCurrent=1 Last=2 First=3 910 + } from '@rockbox-zig/sdk'; 911 + ``` 912 + 913 + ### Selected type shapes 914 + 915 + ```typescript 916 + interface Track { 917 + id?: string; 918 + title: string; artist: string; album: string; 919 + genre: string; albumArtist: string; composer: string; 920 + tracknum: number; discnum: number; year: number; 921 + bitrate: number; frequency: number; 922 + length: number; // duration in ms 923 + elapsed: number; // current position in ms 924 + filesize: number; path: string; 925 + albumId?: string; artistId?: string; albumArt?: string; 926 + } 927 + 928 + interface Album { 929 + id: string; title: string; artist: string; 930 + year: number; artistId: string; md5: string; 931 + albumArt?: string; 932 + tracks: Track[]; 933 + } 934 + 935 + interface Playlist { 936 + amount: number; index: number; maxPlaylistSize: number; 937 + firstIndex: number; lastInsertPos: number; 938 + seed: number; lastShuffledStart: number; 939 + tracks: Track[]; 940 + } 941 + 942 + interface Device { 943 + id: string; name: string; host: string; 944 + ip: string; port: number; service: string; 945 + isConnected: boolean; 946 + isCastDevice: boolean; isSourceDevice: boolean; isCurrentDevice: boolean; 947 + baseUrl?: string; 948 + } 949 + ``` 950 + 951 + ### Helper functions 952 + 953 + ```typescript 954 + import { isDirectory } from '@rockbox-zig/sdk'; 955 + 956 + const entries = await client.browse.entries('/Music'); 957 + const dirs = entries.filter(isDirectory); 958 + const files = entries.filter((e) => !isDirectory(e)); 959 + ```
+186
sdk/typescript/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "typescript", 7 + "dependencies": { 8 + "graphql-ws": "^6.0.8", 9 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest", 12 + "vitest": "^4.1.5", 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5", 16 + }, 17 + }, 18 + }, 19 + "packages": { 20 + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], 21 + 22 + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], 23 + 24 + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], 25 + 26 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 27 + 28 + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], 29 + 30 + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], 31 + 32 + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], 33 + 34 + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], 35 + 36 + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], 37 + 38 + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], 39 + 40 + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], 41 + 42 + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], 43 + 44 + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], 45 + 46 + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], 47 + 48 + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], 49 + 50 + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], 51 + 52 + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], 53 + 54 + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], 55 + 56 + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], 57 + 58 + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], 59 + 60 + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], 61 + 62 + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], 63 + 64 + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], 65 + 66 + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 67 + 68 + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], 69 + 70 + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], 71 + 72 + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], 73 + 74 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 75 + 76 + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], 77 + 78 + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], 79 + 80 + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], 81 + 82 + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], 83 + 84 + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], 85 + 86 + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], 87 + 88 + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], 89 + 90 + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], 91 + 92 + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], 93 + 94 + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], 95 + 96 + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], 97 + 98 + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 99 + 100 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 101 + 102 + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], 103 + 104 + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 105 + 106 + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 107 + 108 + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 109 + 110 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 111 + 112 + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], 113 + 114 + "graphql-ws": ["graphql-ws@6.0.8", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "ws"] }, "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw=="], 115 + 116 + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], 117 + 118 + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], 119 + 120 + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], 121 + 122 + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], 123 + 124 + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], 125 + 126 + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], 127 + 128 + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], 129 + 130 + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], 131 + 132 + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], 133 + 134 + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], 135 + 136 + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], 137 + 138 + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], 139 + 140 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 141 + 142 + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 143 + 144 + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], 145 + 146 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 147 + 148 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 149 + 150 + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], 151 + 152 + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], 153 + 154 + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], 155 + 156 + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], 157 + 158 + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 159 + 160 + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], 161 + 162 + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], 163 + 164 + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], 165 + 166 + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], 167 + 168 + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], 169 + 170 + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], 171 + 172 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 173 + 174 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 175 + 176 + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], 177 + 178 + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], 179 + 180 + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], 181 + 182 + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 183 + 184 + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], 185 + } 186 + }
+44
sdk/typescript/package.json
··· 1 + { 2 + "name": "@rockbox-zig/sdk", 3 + "version": "0.1.0", 4 + "description": "TypeScript SDK for Rockbox — GraphQL API client with subscriptions and plugin system", 5 + "module": "src/index.ts", 6 + "main": "./dist/index.js", 7 + "types": "./dist/index.d.ts", 8 + "type": "module", 9 + "exports": { 10 + ".": { 11 + "import": "./dist/index.js", 12 + "types": "./dist/index.d.ts", 13 + "bun": "./src/index.ts" 14 + } 15 + }, 16 + "scripts": { 17 + "build": "bun build src/index.ts --outdir dist --target node --minify", 18 + "typecheck": "tsc --noEmit", 19 + "test": "vitest run", 20 + "test:watch": "vitest" 21 + }, 22 + "files": [ 23 + "dist", 24 + "src" 25 + ], 26 + "keywords": [ 27 + "rockbox", 28 + "graphql", 29 + "audio", 30 + "music-player", 31 + "sdk" 32 + ], 33 + "license": "MIT", 34 + "devDependencies": { 35 + "@types/bun": "latest", 36 + "vitest": "^4.1.5" 37 + }, 38 + "peerDependencies": { 39 + "typescript": "^5" 40 + }, 41 + "dependencies": { 42 + "graphql-ws": "^6.0.8" 43 + } 44 + }
+26
sdk/typescript/src/api/browse.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { Entry } from '../types.js'; 3 + import { isDirectory } from '../types.js'; 4 + 5 + export class BrowseApi { 6 + constructor(private readonly http: HttpTransport) {} 7 + 8 + async entries(path?: string): Promise<Entry[]> { 9 + const data = await this.http.execute<{ treeGetEntries: Entry[] }>(/* GraphQL */ ` 10 + query Browse($path: String) { 11 + treeGetEntries(path: $path) { name attr timeWrite customaction } 12 + } 13 + `, { path }); 14 + return data.treeGetEntries; 15 + } 16 + 17 + async directories(path?: string): Promise<Entry[]> { 18 + const all = await this.entries(path); 19 + return all.filter(isDirectory); 20 + } 21 + 22 + async files(path?: string): Promise<Entry[]> { 23 + const all = await this.entries(path); 24 + return all.filter((e) => !isDirectory(e)); 25 + } 26 + }
+42
sdk/typescript/src/api/devices.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { Device } from '../types.js'; 3 + 4 + export class DevicesApi { 5 + constructor(private readonly http: HttpTransport) {} 6 + 7 + async list(): Promise<Device[]> { 8 + const data = await this.http.execute<{ devices: Device[] }>(/* GraphQL */ ` 9 + query Devices { 10 + devices { 11 + id name host ip port service app isConnected 12 + baseUrl isCastDevice isSourceDevice isCurrentDevice 13 + } 14 + } 15 + `); 16 + return data.devices; 17 + } 18 + 19 + async get(id: string): Promise<Device | null> { 20 + const data = await this.http.execute<{ device: Device | null }>(/* GraphQL */ ` 21 + query Device($id: String!) { 22 + device(id: $id) { 23 + id name host ip port service app isConnected 24 + baseUrl isCastDevice isSourceDevice isCurrentDevice 25 + } 26 + } 27 + `, { id }); 28 + return data.device; 29 + } 30 + 31 + async connect(id: string): Promise<void> { 32 + await this.http.execute(/* GraphQL */ ` 33 + mutation ConnectDevice($id: String!) { connect(id: $id) } 34 + `, { id }); 35 + } 36 + 37 + async disconnect(id: string): Promise<void> { 38 + await this.http.execute(/* GraphQL */ ` 39 + mutation DisconnectDevice($id: String!) { disconnect(id: $id) } 40 + `, { id }); 41 + } 42 + }
+168
sdk/typescript/src/api/library.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { Track, Album, Artist, SearchResults } from '../types.js'; 3 + 4 + const TRACK_FIELDS = /* GraphQL */ ` 5 + fragment TrackFields on Track { 6 + id title artist album genre disc trackString yearString 7 + composer comment albumArtist grouping 8 + discnum tracknum layer year bitrate frequency 9 + filesize length elapsed path 10 + albumId artistId genreId albumArt 11 + } 12 + `; 13 + 14 + const ALBUM_FIELDS = /* GraphQL */ ` 15 + fragment AlbumFields on Album { 16 + id title artist year yearString albumArt md5 artistId 17 + } 18 + `; 19 + 20 + const ARTIST_FIELDS = /* GraphQL */ ` 21 + fragment ArtistFields on Artist { 22 + id name bio image 23 + } 24 + `; 25 + 26 + export class LibraryApi { 27 + constructor(private readonly http: HttpTransport) {} 28 + 29 + // --------------------------------------------------------------------------- 30 + // Albums 31 + // --------------------------------------------------------------------------- 32 + 33 + async albums(): Promise<Album[]> { 34 + const data = await this.http.execute<{ albums: Album[] }>(/* GraphQL */ ` 35 + ${ALBUM_FIELDS} 36 + query Albums { albums { ...AlbumFields tracks { id title path length albumArt } } } 37 + `); 38 + return data.albums; 39 + } 40 + 41 + async album(id: string): Promise<Album | null> { 42 + const data = await this.http.execute<{ album: Album | null }>(/* GraphQL */ ` 43 + ${TRACK_FIELDS} 44 + ${ALBUM_FIELDS} 45 + query Album($id: String!) { 46 + album(id: $id) { ...AlbumFields tracks { ...TrackFields } } 47 + } 48 + `, { id }); 49 + return data.album; 50 + } 51 + 52 + async likedAlbums(): Promise<Album[]> { 53 + const data = await this.http.execute<{ likedAlbums: Album[] }>(/* GraphQL */ ` 54 + ${ALBUM_FIELDS} 55 + query LikedAlbums { likedAlbums { ...AlbumFields } } 56 + `); 57 + return data.likedAlbums; 58 + } 59 + 60 + async likeAlbum(id: string): Promise<void> { 61 + await this.http.execute(/* GraphQL */ ` 62 + mutation LikeAlbum($id: String!) { likeAlbum(id: $id) } 63 + `, { id }); 64 + } 65 + 66 + async unlikeAlbum(id: string): Promise<void> { 67 + await this.http.execute(/* GraphQL */ ` 68 + mutation UnlikeAlbum($id: String!) { unlikeAlbum(id: $id) } 69 + `, { id }); 70 + } 71 + 72 + // --------------------------------------------------------------------------- 73 + // Artists 74 + // --------------------------------------------------------------------------- 75 + 76 + async artists(): Promise<Artist[]> { 77 + const data = await this.http.execute<{ artists: Artist[] }>(/* GraphQL */ ` 78 + ${ARTIST_FIELDS} 79 + query Artists { artists { ...ArtistFields albums { id title albumArt year } } } 80 + `); 81 + return data.artists; 82 + } 83 + 84 + async artist(id: string): Promise<Artist | null> { 85 + const data = await this.http.execute<{ artist: Artist | null }>(/* GraphQL */ ` 86 + ${ARTIST_FIELDS} 87 + ${TRACK_FIELDS} 88 + query Artist($id: String!) { 89 + artist(id: $id) { 90 + ...ArtistFields 91 + albums { id title albumArt year yearString md5 artistId tracks { id title path length } } 92 + tracks { ...TrackFields } 93 + } 94 + } 95 + `, { id }); 96 + return data.artist; 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Tracks 101 + // --------------------------------------------------------------------------- 102 + 103 + async tracks(): Promise<Track[]> { 104 + const data = await this.http.execute<{ tracks: Track[] }>(/* GraphQL */ ` 105 + ${TRACK_FIELDS} 106 + query Tracks { tracks { ...TrackFields } } 107 + `); 108 + return data.tracks; 109 + } 110 + 111 + async track(id: string): Promise<Track | null> { 112 + const data = await this.http.execute<{ track: Track | null }>(/* GraphQL */ ` 113 + ${TRACK_FIELDS} 114 + query Track($id: String!) { track(id: $id) { ...TrackFields } } 115 + `, { id }); 116 + return data.track; 117 + } 118 + 119 + async likedTracks(): Promise<Track[]> { 120 + const data = await this.http.execute<{ likedTracks: Track[] }>(/* GraphQL */ ` 121 + ${TRACK_FIELDS} 122 + query LikedTracks { likedTracks { ...TrackFields } } 123 + `); 124 + return data.likedTracks; 125 + } 126 + 127 + async likeTrack(id: string): Promise<void> { 128 + await this.http.execute(/* GraphQL */ ` 129 + mutation LikeTrack($id: String!) { likeTrack(id: $id) } 130 + `, { id }); 131 + } 132 + 133 + async unlikeTrack(id: string): Promise<void> { 134 + await this.http.execute(/* GraphQL */ ` 135 + mutation UnlikeTrack($id: String!) { unlikeTrack(id: $id) } 136 + `, { id }); 137 + } 138 + 139 + // --------------------------------------------------------------------------- 140 + // Search 141 + // --------------------------------------------------------------------------- 142 + 143 + async search(term: string): Promise<SearchResults> { 144 + const data = await this.http.execute<{ search: SearchResults }>(/* GraphQL */ ` 145 + ${TRACK_FIELDS} 146 + ${ALBUM_FIELDS} 147 + ${ARTIST_FIELDS} 148 + query Search($term: String!) { 149 + search(term: $term) { 150 + artists { ...ArtistFields } 151 + albums { ...AlbumFields } 152 + tracks { ...TrackFields } 153 + likedTracks { ...TrackFields } 154 + likedAlbums { ...AlbumFields } 155 + } 156 + } 157 + `, { term }); 158 + return data.search; 159 + } 160 + 161 + // --------------------------------------------------------------------------- 162 + // Library management 163 + // --------------------------------------------------------------------------- 164 + 165 + async scan(): Promise<void> { 166 + await this.http.execute(/* GraphQL */ `mutation ScanLibrary { scanLibrary }`); 167 + } 168 + }
+169
sdk/typescript/src/api/playback.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { Track } from '../types.js'; 3 + import { PlaybackStatus } from '../types.js'; 4 + 5 + const TRACK_FIELDS = /* GraphQL */ ` 6 + fragment TrackFields on Track { 7 + id title artist album genre disc trackString yearString 8 + composer comment albumArtist grouping 9 + discnum tracknum layer year bitrate frequency 10 + filesize length elapsed path 11 + albumId artistId genreId albumArt 12 + } 13 + `; 14 + 15 + export class PlaybackApi { 16 + constructor(private readonly http: HttpTransport) {} 17 + 18 + /** Raw numeric playback status from the firmware */ 19 + async rawStatus(): Promise<number> { 20 + const data = await this.http.execute<{ status: number }>(/* GraphQL */ ` 21 + query PlaybackStatus { status } 22 + `); 23 + return data.status; 24 + } 25 + 26 + /** Typed playback status */ 27 + async status(): Promise<PlaybackStatus> { 28 + return this.rawStatus() as Promise<PlaybackStatus>; 29 + } 30 + 31 + async currentTrack(): Promise<Track | null> { 32 + const data = await this.http.execute<{ currentTrack: Track | null }>(/* GraphQL */ ` 33 + ${TRACK_FIELDS} 34 + query CurrentTrack { currentTrack { ...TrackFields } } 35 + `); 36 + return data.currentTrack; 37 + } 38 + 39 + async nextTrack(): Promise<Track | null> { 40 + const data = await this.http.execute<{ nextTrack: Track | null }>(/* GraphQL */ ` 41 + ${TRACK_FIELDS} 42 + query NextTrack { nextTrack { ...TrackFields } } 43 + `); 44 + return data.nextTrack; 45 + } 46 + 47 + async filePosition(): Promise<number> { 48 + const data = await this.http.execute<{ getFilePosition: number }>(/* GraphQL */ ` 49 + query FilePosition { getFilePosition } 50 + `); 51 + return data.getFilePosition; 52 + } 53 + 54 + // --------------------------------------------------------------------------- 55 + // Transport controls 56 + // --------------------------------------------------------------------------- 57 + 58 + async play(elapsed = 0, offset = 0): Promise<void> { 59 + await this.http.execute(/* GraphQL */ ` 60 + mutation Play($elapsed: Long!, $offset: Long!) { play(elapsed: $elapsed, offset: $offset) } 61 + `, { elapsed, offset }); 62 + } 63 + 64 + async pause(): Promise<void> { 65 + await this.http.execute(/* GraphQL */ `mutation Pause { pause }`); 66 + } 67 + 68 + async resume(): Promise<void> { 69 + await this.http.execute(/* GraphQL */ `mutation Resume { resume }`); 70 + } 71 + 72 + async next(): Promise<void> { 73 + await this.http.execute(/* GraphQL */ `mutation Next { next }`); 74 + } 75 + 76 + async previous(): Promise<void> { 77 + await this.http.execute(/* GraphQL */ `mutation Previous { previous }`); 78 + } 79 + 80 + /** Seek to an absolute position in milliseconds */ 81 + async seek(positionMs: number): Promise<void> { 82 + await this.http.execute(/* GraphQL */ ` 83 + mutation Seek($newTime: Int!) { fastForwardRewind(newTime: $newTime) } 84 + `, { newTime: positionMs }); 85 + } 86 + 87 + async stop(): Promise<void> { 88 + await this.http.execute(/* GraphQL */ `mutation Stop { hardStop }`); 89 + } 90 + 91 + /** Reload and flush the current track queue */ 92 + async flushAndReload(): Promise<void> { 93 + await this.http.execute(/* GraphQL */ `mutation FlushReload { flushAndReloadTracks }`); 94 + } 95 + 96 + // --------------------------------------------------------------------------- 97 + // Play helpers — single-call shortcuts (inspired by Navidrome & Kodi) 98 + // --------------------------------------------------------------------------- 99 + 100 + async playTrack(path: string): Promise<void> { 101 + await this.http.execute(/* GraphQL */ ` 102 + mutation PlayTrack($path: String!) { playTrack(path: $path) } 103 + `, { path }); 104 + } 105 + 106 + async playAlbum( 107 + albumId: string, 108 + options: { shuffle?: boolean; position?: number } = {}, 109 + ): Promise<void> { 110 + await this.http.execute(/* GraphQL */ ` 111 + mutation PlayAlbum($albumId: String!, $shuffle: Boolean, $position: Int) { 112 + playAlbum(albumId: $albumId, shuffle: $shuffle, position: $position) 113 + } 114 + `, { albumId, ...options }); 115 + } 116 + 117 + async playArtist( 118 + artistId: string, 119 + options: { shuffle?: boolean; position?: number } = {}, 120 + ): Promise<void> { 121 + await this.http.execute(/* GraphQL */ ` 122 + mutation PlayArtist($artistId: String!, $shuffle: Boolean, $position: Int) { 123 + playArtistTracks(artistId: $artistId, shuffle: $shuffle, position: $position) 124 + } 125 + `, { artistId, ...options }); 126 + } 127 + 128 + async playPlaylist( 129 + playlistId: string, 130 + options: { shuffle?: boolean; position?: number } = {}, 131 + ): Promise<void> { 132 + await this.http.execute(/* GraphQL */ ` 133 + mutation PlayPlaylist($playlistId: String!, $shuffle: Boolean, $position: Int) { 134 + playPlaylist(playlistId: $playlistId, shuffle: $shuffle, position: $position) 135 + } 136 + `, { playlistId, ...options }); 137 + } 138 + 139 + async playDirectory( 140 + path: string, 141 + options: { recurse?: boolean; shuffle?: boolean; position?: number } = {}, 142 + ): Promise<void> { 143 + await this.http.execute(/* GraphQL */ ` 144 + mutation PlayDirectory($path: String!, $recurse: Boolean, $shuffle: Boolean, $position: Int) { 145 + playDirectory(path: $path, recurse: $recurse, shuffle: $shuffle, position: $position) 146 + } 147 + `, { path, ...options }); 148 + } 149 + 150 + async playLikedTracks( 151 + options: { shuffle?: boolean; position?: number } = {}, 152 + ): Promise<void> { 153 + await this.http.execute(/* GraphQL */ ` 154 + mutation PlayLikedTracks($shuffle: Boolean, $position: Int) { 155 + playLikedTracks(shuffle: $shuffle, position: $position) 156 + } 157 + `, options); 158 + } 159 + 160 + async playAllTracks( 161 + options: { shuffle?: boolean; position?: number } = {}, 162 + ): Promise<void> { 163 + await this.http.execute(/* GraphQL */ ` 164 + mutation PlayAllTracks($shuffle: Boolean, $position: Int) { 165 + playAllTracks(shuffle: $shuffle, position: $position) 166 + } 167 + `, options); 168 + } 169 + }
+120
sdk/typescript/src/api/playlist.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { Playlist } from '../types.js'; 3 + import { InsertPosition } from '../types.js'; 4 + 5 + const TRACK_FIELDS = /* GraphQL */ ` 6 + fragment TrackFields on Track { 7 + id title artist album genre disc trackString yearString 8 + composer comment albumArtist grouping 9 + discnum tracknum layer year bitrate frequency 10 + filesize length elapsed path 11 + albumId artistId genreId albumArt 12 + } 13 + `; 14 + 15 + export class PlaylistApi { 16 + constructor(private readonly http: HttpTransport) {} 17 + 18 + async current(): Promise<Playlist> { 19 + const data = await this.http.execute<{ playlistGetCurrent: Playlist }>(/* GraphQL */ ` 20 + ${TRACK_FIELDS} 21 + query CurrentPlaylist { 22 + playlistGetCurrent { 23 + amount index maxPlaylistSize firstIndex 24 + lastInsertPos seed lastShuffledStart 25 + tracks { ...TrackFields } 26 + } 27 + } 28 + `); 29 + return data.playlistGetCurrent; 30 + } 31 + 32 + async amount(): Promise<number> { 33 + const data = await this.http.execute<{ playlistAmount: number }>(/* GraphQL */ ` 34 + query PlaylistAmount { playlistAmount } 35 + `); 36 + return data.playlistAmount; 37 + } 38 + 39 + // --------------------------------------------------------------------------- 40 + // Queue management 41 + // --------------------------------------------------------------------------- 42 + 43 + /** 44 + * Insert tracks into the current playlist. 45 + * @param paths File paths or track IDs to insert 46 + * @param position Where to insert (default: Next after current) 47 + * @param playlistId Target playlist ID; omit for the active queue 48 + */ 49 + async insertTracks( 50 + paths: string[], 51 + position: InsertPosition = InsertPosition.Next, 52 + playlistId?: string, 53 + ): Promise<void> { 54 + await this.http.execute(/* GraphQL */ ` 55 + mutation InsertTracks($playlistId: String, $position: Int!, $tracks: [String!]!) { 56 + insertTracks(playlistId: $playlistId, position: $position, tracks: $tracks) 57 + } 58 + `, { playlistId, position, tracks: paths }); 59 + } 60 + 61 + /** Insert a directory (optionally recursive) into the queue */ 62 + async insertDirectory( 63 + directory: string, 64 + position: InsertPosition = InsertPosition.Last, 65 + playlistId?: string, 66 + ): Promise<void> { 67 + await this.http.execute(/* GraphQL */ ` 68 + mutation InsertDirectory($playlistId: String, $position: Int!, $directory: String!) { 69 + insertDirectory(playlistId: $playlistId, position: $position, directory: $directory) 70 + } 71 + `, { playlistId, position, directory }); 72 + } 73 + 74 + /** Append all tracks from an album to the queue */ 75 + async insertAlbum(albumId: string, position: InsertPosition = InsertPosition.Last): Promise<void> { 76 + await this.http.execute(/* GraphQL */ ` 77 + mutation InsertAlbum($albumId: String!, $position: Int!) { 78 + insertAlbum(albumId: $albumId, position: $position) 79 + } 80 + `, { albumId, position }); 81 + } 82 + 83 + async removeTrack(index: number): Promise<void> { 84 + await this.http.execute(/* GraphQL */ ` 85 + mutation RemoveTrack($index: Int!) { playlistRemoveTrack(index: $index) } 86 + `, { index }); 87 + } 88 + 89 + async clear(): Promise<void> { 90 + await this.http.execute(/* GraphQL */ `mutation ClearPlaylist { playlistRemoveAllTracks }`); 91 + } 92 + 93 + async shuffle(): Promise<void> { 94 + await this.http.execute(/* GraphQL */ `mutation ShufflePlaylist { shufflePlaylist }`); 95 + } 96 + 97 + /** 98 + * Create and start a new temporary playlist from a list of paths. 99 + * This replaces the current queue. 100 + */ 101 + async create(name: string, tracks: string[]): Promise<void> { 102 + await this.http.execute(/* GraphQL */ ` 103 + mutation CreatePlaylist($name: String!, $tracks: [String!]!) { 104 + playlistCreate(name: $name, tracks: $tracks) 105 + } 106 + `, { name, tracks }); 107 + } 108 + 109 + async start(options: { startIndex?: number; elapsed?: number; offset?: number } = {}): Promise<void> { 110 + await this.http.execute(/* GraphQL */ ` 111 + mutation PlaylistStart($startIndex: Int, $elapsed: Int, $offset: Int) { 112 + playlistStart(startIndex: $startIndex, elapsed: $elapsed, offset: $offset) 113 + } 114 + `, options); 115 + } 116 + 117 + async resume(): Promise<void> { 118 + await this.http.execute(/* GraphQL */ `mutation PlaylistResume { playlistResume }`); 119 + } 120 + }
+137
sdk/typescript/src/api/saved-playlists.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { SavedPlaylist, SavedPlaylistFolder } from '../types.js'; 3 + 4 + export interface CreatePlaylistInput { 5 + name: string; 6 + description?: string; 7 + image?: string; 8 + folderId?: string; 9 + trackIds?: string[]; 10 + } 11 + 12 + export interface UpdatePlaylistInput { 13 + name: string; 14 + description?: string; 15 + image?: string; 16 + folderId?: string; 17 + } 18 + 19 + export class SavedPlaylistsApi { 20 + constructor(private readonly http: HttpTransport) {} 21 + 22 + async list(folderId?: string): Promise<SavedPlaylist[]> { 23 + const data = await this.http.execute<{ savedPlaylists: SavedPlaylist[] }>(/* GraphQL */ ` 24 + query SavedPlaylists($folderId: String) { 25 + savedPlaylists(folderId: $folderId) { 26 + id name description image folderId trackCount createdAt updatedAt 27 + } 28 + } 29 + `, { folderId }); 30 + return data.savedPlaylists; 31 + } 32 + 33 + async get(id: string): Promise<SavedPlaylist | null> { 34 + const data = await this.http.execute<{ savedPlaylist: SavedPlaylist | null }>(/* GraphQL */ ` 35 + query SavedPlaylist($id: String!) { 36 + savedPlaylist(id: $id) { 37 + id name description image folderId trackCount createdAt updatedAt 38 + } 39 + } 40 + `, { id }); 41 + return data.savedPlaylist; 42 + } 43 + 44 + async trackIds(playlistId: string): Promise<string[]> { 45 + const data = await this.http.execute<{ savedPlaylistTrackIds: string[] }>(/* GraphQL */ ` 46 + query SavedPlaylistTrackIds($playlistId: String!) { 47 + savedPlaylistTrackIds(playlistId: $playlistId) 48 + } 49 + `, { playlistId }); 50 + return data.savedPlaylistTrackIds; 51 + } 52 + 53 + async create(input: CreatePlaylistInput): Promise<SavedPlaylist> { 54 + const data = await this.http.execute<{ createSavedPlaylist: SavedPlaylist }>(/* GraphQL */ ` 55 + mutation CreateSavedPlaylist( 56 + $name: String!, $description: String, $image: String, 57 + $folderId: String, $trackIds: [String!] 58 + ) { 59 + createSavedPlaylist( 60 + name: $name, description: $description, image: $image, 61 + folderId: $folderId, trackIds: $trackIds 62 + ) { 63 + id name description image folderId trackCount createdAt updatedAt 64 + } 65 + } 66 + `, input); 67 + return data.createSavedPlaylist; 68 + } 69 + 70 + async update(id: string, input: UpdatePlaylistInput): Promise<void> { 71 + await this.http.execute(/* GraphQL */ ` 72 + mutation UpdateSavedPlaylist( 73 + $id: String!, $name: String!, $description: String, $image: String, $folderId: String 74 + ) { 75 + updateSavedPlaylist( 76 + id: $id, name: $name, description: $description, image: $image, folderId: $folderId 77 + ) 78 + } 79 + `, { id, ...input }); 80 + } 81 + 82 + async delete(id: string): Promise<void> { 83 + await this.http.execute(/* GraphQL */ ` 84 + mutation DeleteSavedPlaylist($id: String!) { deleteSavedPlaylist(id: $id) } 85 + `, { id }); 86 + } 87 + 88 + async addTracks(playlistId: string, trackIds: string[]): Promise<void> { 89 + await this.http.execute(/* GraphQL */ ` 90 + mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { 91 + addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) 92 + } 93 + `, { playlistId, trackIds }); 94 + } 95 + 96 + async removeTrack(playlistId: string, trackId: string): Promise<void> { 97 + await this.http.execute(/* GraphQL */ ` 98 + mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { 99 + removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) 100 + } 101 + `, { playlistId, trackId }); 102 + } 103 + 104 + async play(playlistId: string): Promise<void> { 105 + await this.http.execute(/* GraphQL */ ` 106 + mutation PlaySavedPlaylist($playlistId: String!) { playSavedPlaylist(playlistId: $playlistId) } 107 + `, { playlistId }); 108 + } 109 + 110 + // --------------------------------------------------------------------------- 111 + // Folders 112 + // --------------------------------------------------------------------------- 113 + 114 + async folders(): Promise<SavedPlaylistFolder[]> { 115 + const data = await this.http.execute<{ playlistFolders: SavedPlaylistFolder[] }>(/* GraphQL */ ` 116 + query PlaylistFolders { 117 + playlistFolders { id name createdAt updatedAt } 118 + } 119 + `); 120 + return data.playlistFolders; 121 + } 122 + 123 + async createFolder(name: string): Promise<SavedPlaylistFolder> { 124 + const data = await this.http.execute<{ createPlaylistFolder: SavedPlaylistFolder }>(/* GraphQL */ ` 125 + mutation CreatePlaylistFolder($name: String!) { 126 + createPlaylistFolder(name: $name) { id name createdAt updatedAt } 127 + } 128 + `, { name }); 129 + return data.createPlaylistFolder; 130 + } 131 + 132 + async deleteFolder(id: string): Promise<void> { 133 + await this.http.execute(/* GraphQL */ ` 134 + mutation DeletePlaylistFolder($id: String!) { deletePlaylistFolder(id: $id) } 135 + `, { id }); 136 + } 137 + }
+32
sdk/typescript/src/api/settings.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { UserSettings, PartialUserSettings } from '../types.js'; 3 + 4 + export class SettingsApi { 5 + constructor(private readonly http: HttpTransport) {} 6 + 7 + async get(): Promise<UserSettings> { 8 + const data = await this.http.execute<{ globalSettings: UserSettings }>(/* GraphQL */ ` 9 + query GlobalSettings { 10 + globalSettings { 11 + musicDir volume balance bass treble channelConfig stereoWidth 12 + eqEnabled eqPrecut 13 + eqBandSettings { cutoff q gain } 14 + replaygainSettings { noclip type preamp } 15 + compressorSettings { threshold makeupGain ratio knee releaseTime attackTime } 16 + crossfadeEnabled crossfadeFadeInDelay crossfadeFadeInDuration 17 + crossfadeFadeOutDelay crossfadeFadeOutDuration crossfadeFadeOutMixmode 18 + crossfeedEnabled crossfeedDirectGain crossfeedCrossGain 19 + crossfeedHfAttenuation crossfeedHfCutoff 20 + repeatMode singleMode partyMode shuffle playerName 21 + } 22 + } 23 + `); 24 + return data.globalSettings; 25 + } 26 + 27 + async save(settings: PartialUserSettings): Promise<void> { 28 + await this.http.execute(/* GraphQL */ ` 29 + mutation SaveSettings($settings: NewGlobalSettings!) { saveSettings(settings: $settings) } 30 + `, { settings }); 31 + } 32 + }
+121
sdk/typescript/src/api/smart-playlists.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { SmartPlaylist, TrackStats } from '../types.js'; 3 + 4 + export interface CreateSmartPlaylistInput { 5 + name: string; 6 + rules: string; 7 + description?: string; 8 + image?: string; 9 + folderId?: string; 10 + } 11 + 12 + export interface UpdateSmartPlaylistInput { 13 + name: string; 14 + rules: string; 15 + description?: string; 16 + image?: string; 17 + folderId?: string; 18 + } 19 + 20 + export class SmartPlaylistsApi { 21 + constructor(private readonly http: HttpTransport) {} 22 + 23 + async list(): Promise<SmartPlaylist[]> { 24 + const data = await this.http.execute<{ smartPlaylists: SmartPlaylist[] }>(/* GraphQL */ ` 25 + query SmartPlaylists { 26 + smartPlaylists { 27 + id name description image folderId isSystem rules createdAt updatedAt 28 + } 29 + } 30 + `); 31 + return data.smartPlaylists; 32 + } 33 + 34 + async get(id: string): Promise<SmartPlaylist | null> { 35 + const data = await this.http.execute<{ smartPlaylist: SmartPlaylist | null }>(/* GraphQL */ ` 36 + query SmartPlaylist($id: String!) { 37 + smartPlaylist(id: $id) { 38 + id name description image folderId isSystem rules createdAt updatedAt 39 + } 40 + } 41 + `, { id }); 42 + return data.smartPlaylist; 43 + } 44 + 45 + async trackIds(id: string): Promise<string[]> { 46 + const data = await this.http.execute<{ smartPlaylistTrackIds: string[] }>(/* GraphQL */ ` 47 + query SmartPlaylistTrackIds($id: String!) { smartPlaylistTrackIds(id: $id) } 48 + `, { id }); 49 + return data.smartPlaylistTrackIds; 50 + } 51 + 52 + async create(input: CreateSmartPlaylistInput): Promise<SmartPlaylist> { 53 + const data = await this.http.execute<{ createSmartPlaylist: SmartPlaylist }>(/* GraphQL */ ` 54 + mutation CreateSmartPlaylist( 55 + $name: String!, $rules: String!, $description: String, 56 + $image: String, $folderId: String 57 + ) { 58 + createSmartPlaylist( 59 + name: $name, rules: $rules, description: $description, 60 + image: $image, folderId: $folderId 61 + ) { 62 + id name description image folderId isSystem rules createdAt updatedAt 63 + } 64 + } 65 + `, input); 66 + return data.createSmartPlaylist; 67 + } 68 + 69 + async update(id: string, input: UpdateSmartPlaylistInput): Promise<void> { 70 + await this.http.execute(/* GraphQL */ ` 71 + mutation UpdateSmartPlaylist( 72 + $id: String!, $name: String!, $rules: String!, 73 + $description: String, $image: String, $folderId: String 74 + ) { 75 + updateSmartPlaylist( 76 + id: $id, name: $name, rules: $rules, description: $description, 77 + image: $image, folderId: $folderId 78 + ) 79 + } 80 + `, { id, ...input }); 81 + } 82 + 83 + async delete(id: string): Promise<void> { 84 + await this.http.execute(/* GraphQL */ ` 85 + mutation DeleteSmartPlaylist($id: String!) { deleteSmartPlaylist(id: $id) } 86 + `, { id }); 87 + } 88 + 89 + async play(id: string): Promise<void> { 90 + await this.http.execute(/* GraphQL */ ` 91 + mutation PlaySmartPlaylist($id: String!) { playSmartPlaylist(id: $id) } 92 + `, { id }); 93 + } 94 + 95 + // --------------------------------------------------------------------------- 96 + // Listening stats (feeds smart playlist rules) 97 + // --------------------------------------------------------------------------- 98 + 99 + async trackStats(trackId: string): Promise<TrackStats | null> { 100 + const data = await this.http.execute<{ trackStats: TrackStats | null }>(/* GraphQL */ ` 101 + query TrackStats($trackId: String!) { 102 + trackStats(trackId: $trackId) { 103 + trackId playCount skipCount lastPlayed lastSkipped updatedAt 104 + } 105 + } 106 + `, { trackId }); 107 + return data.trackStats; 108 + } 109 + 110 + async recordPlayed(trackId: string): Promise<void> { 111 + await this.http.execute(/* GraphQL */ ` 112 + mutation RecordTrackPlayed($trackId: String!) { recordTrackPlayed(trackId: $trackId) } 113 + `, { trackId }); 114 + } 115 + 116 + async recordSkipped(trackId: string): Promise<void> { 117 + await this.http.execute(/* GraphQL */ ` 118 + mutation RecordTrackSkipped($trackId: String!) { recordTrackSkipped(trackId: $trackId) } 119 + `, { trackId }); 120 + } 121 + }
+23
sdk/typescript/src/api/sound.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + 3 + export class SoundApi { 4 + constructor(private readonly http: HttpTransport) {} 5 + 6 + /** Adjust volume by a relative number of steps (positive = louder, negative = quieter) */ 7 + async adjustVolume(steps: number): Promise<number> { 8 + const data = await this.http.execute<{ adjustVolume: number }>(/* GraphQL */ ` 9 + mutation AdjustVolume($steps: Int!) { adjustVolume(steps: $steps) } 10 + `, { steps }); 11 + return data.adjustVolume; 12 + } 13 + 14 + /** Increase volume by one step */ 15 + async volumeUp(): Promise<number> { 16 + return this.adjustVolume(1); 17 + } 18 + 19 + /** Decrease volume by one step */ 20 + async volumeDown(): Promise<number> { 21 + return this.adjustVolume(-1); 22 + } 23 + }
+26
sdk/typescript/src/api/system.ts
··· 1 + import type { HttpTransport } from '../transport.js'; 2 + import type { SystemStatus } from '../types.js'; 3 + 4 + export class SystemApi { 5 + constructor(private readonly http: HttpTransport) {} 6 + 7 + async version(): Promise<string> { 8 + const data = await this.http.execute<{ rockboxVersion: string }>(/* GraphQL */ ` 9 + query Version { rockboxVersion } 10 + `); 11 + return data.rockboxVersion; 12 + } 13 + 14 + async status(): Promise<SystemStatus> { 15 + const data = await this.http.execute<{ globalStatus: SystemStatus }>(/* GraphQL */ ` 16 + query GlobalStatus { 17 + globalStatus { 18 + resumeIndex resumeCrc32 resumeElapsed resumeOffset 19 + runtime topruntime dircacheSize 20 + lastScreen viewerIconCount lastVolumeChange 21 + } 22 + } 23 + `); 24 + return data.globalStatus; 25 + } 26 + }
+185
sdk/typescript/src/client.ts
··· 1 + import { HttpTransport, WsTransport } from './transport.js'; 2 + import { TypedEventEmitter, type RockboxEventMap } from './events.js'; 3 + import { PluginRegistry, type RockboxPlugin } from './plugin.js'; 4 + 5 + import { PlaybackApi } from './api/playback.js'; 6 + import { LibraryApi } from './api/library.js'; 7 + import { PlaylistApi } from './api/playlist.js'; 8 + import { SavedPlaylistsApi } from './api/saved-playlists.js'; 9 + import { SmartPlaylistsApi } from './api/smart-playlists.js'; 10 + import { SoundApi } from './api/sound.js'; 11 + import { SettingsApi } from './api/settings.js'; 12 + import { SystemApi } from './api/system.js'; 13 + import { BrowseApi } from './api/browse.js'; 14 + import { DevicesApi } from './api/devices.js'; 15 + 16 + import type { Track, Playlist } from './types.js'; 17 + 18 + export interface RockboxClientConfig { 19 + /** Hostname or IP of the rockboxd instance (default: "localhost") */ 20 + host?: string; 21 + /** GraphQL HTTP/WS port (default: 6062) */ 22 + port?: number; 23 + /** Override the full HTTP URL (takes precedence over host/port) */ 24 + httpUrl?: string; 25 + /** Override the full WebSocket URL (takes precedence over host/port) */ 26 + wsUrl?: string; 27 + } 28 + 29 + // --------------------------------------------------------------------------- 30 + // RockboxClient — main entry point 31 + // --------------------------------------------------------------------------- 32 + // Inspired by: 33 + // Mopidy — domain namespace API (client.playback.play(), client.library.search()) 34 + // Jellyfin — plugin system with install/uninstall lifecycle 35 + // Kodi — rich device and playlist management 36 + // Navidrome — clean typed search & library queries 37 + // --------------------------------------------------------------------------- 38 + 39 + export class RockboxClient extends TypedEventEmitter<RockboxEventMap> { 40 + readonly playback: PlaybackApi; 41 + readonly library: LibraryApi; 42 + readonly playlist: PlaylistApi; 43 + readonly savedPlaylists: SavedPlaylistsApi; 44 + readonly smartPlaylists: SmartPlaylistsApi; 45 + readonly sound: SoundApi; 46 + readonly settings: SettingsApi; 47 + readonly system: SystemApi; 48 + readonly browse: BrowseApi; 49 + readonly devices: DevicesApi; 50 + 51 + private readonly http: HttpTransport; 52 + private readonly ws: WsTransport; 53 + private readonly plugins = new PluginRegistry(); 54 + 55 + /** Unsubscribe handles returned by graphql-ws */ 56 + private subscriptions: Array<() => void> = []; 57 + 58 + constructor(config: RockboxClientConfig = {}) { 59 + super(); 60 + const host = config.host ?? 'localhost'; 61 + const port = config.port ?? 6062; 62 + const httpUrl = config.httpUrl ?? `http://${host}:${port}/graphql`; 63 + const wsUrl = config.wsUrl ?? `ws://${host}:${port}/graphql`; 64 + 65 + this.http = new HttpTransport(httpUrl); 66 + this.ws = new WsTransport(wsUrl); 67 + 68 + this.playback = new PlaybackApi(this.http); 69 + this.library = new LibraryApi(this.http); 70 + this.playlist = new PlaylistApi(this.http); 71 + this.savedPlaylists = new SavedPlaylistsApi(this.http); 72 + this.smartPlaylists = new SmartPlaylistsApi(this.http); 73 + this.sound = new SoundApi(this.http); 74 + this.settings = new SettingsApi(this.http); 75 + this.system = new SystemApi(this.http); 76 + this.browse = new BrowseApi(this.http); 77 + this.devices = new DevicesApi(this.http); 78 + } 79 + 80 + // --------------------------------------------------------------------------- 81 + // Real-time subscriptions 82 + // --------------------------------------------------------------------------- 83 + 84 + /** 85 + * Start all GraphQL subscriptions and forward events to the event emitter. 86 + * Call once after construction. Safe to call multiple times (no-op if already started). 87 + */ 88 + connect(): this { 89 + if (this.subscriptions.length > 0) return this; 90 + 91 + const trackSub = this.ws.subscribe<{ currentlyPlayingSong: Track }>( 92 + /* GraphQL */ ` 93 + subscription CurrentlyPlaying { 94 + currentlyPlayingSong { 95 + id title artist album albumArt albumId artistId path length elapsed 96 + } 97 + } 98 + `, 99 + undefined, 100 + { 101 + next: ({ data }) => { 102 + if (data?.currentlyPlayingSong) { 103 + this.emit('track:changed', data.currentlyPlayingSong); 104 + } 105 + }, 106 + error: (err) => this.emit('ws:error', err instanceof Error ? err : new Error(String(err))), 107 + complete: () => {}, 108 + }, 109 + ); 110 + 111 + const statusSub = this.ws.subscribe<{ playbackStatus: { status: number } }>( 112 + /* GraphQL */ `subscription PlaybackStatus { playbackStatus { status } }`, 113 + undefined, 114 + { 115 + next: ({ data }) => { 116 + if (data?.playbackStatus != null) { 117 + this.emit('status:changed', data.playbackStatus.status); 118 + } 119 + }, 120 + error: (err) => this.emit('ws:error', err instanceof Error ? err : new Error(String(err))), 121 + complete: () => {}, 122 + }, 123 + ); 124 + 125 + const playlistSub = this.ws.subscribe<{ playlistChanged: Playlist }>( 126 + /* GraphQL */ ` 127 + subscription PlaylistChanged { 128 + playlistChanged { 129 + amount index maxPlaylistSize firstIndex lastInsertPos seed lastShuffledStart 130 + tracks { id title artist album path length albumArt } 131 + } 132 + } 133 + `, 134 + undefined, 135 + { 136 + next: ({ data }) => { 137 + if (data?.playlistChanged) { 138 + this.emit('playlist:changed', data.playlistChanged); 139 + } 140 + }, 141 + error: (err) => this.emit('ws:error', err instanceof Error ? err : new Error(String(err))), 142 + complete: () => {}, 143 + }, 144 + ); 145 + 146 + this.subscriptions.push(trackSub, statusSub, playlistSub); 147 + return this; 148 + } 149 + 150 + /** Tear down all subscriptions and the WebSocket connection */ 151 + disconnect(): void { 152 + for (const unsub of this.subscriptions) unsub(); 153 + this.subscriptions = []; 154 + this.ws.dispose(); 155 + } 156 + 157 + // --------------------------------------------------------------------------- 158 + // Plugin system (Jellyfin-style) 159 + // --------------------------------------------------------------------------- 160 + 161 + async use(plugin: RockboxPlugin): Promise<this> { 162 + await this.plugins.register(plugin, { 163 + query: (gql, variables) => this.http.execute(gql, variables), 164 + events: this, 165 + }); 166 + return this; 167 + } 168 + 169 + async unuse(name: string): Promise<this> { 170 + await this.plugins.unregister(name); 171 + return this; 172 + } 173 + 174 + installedPlugins(): RockboxPlugin[] { 175 + return this.plugins.list(); 176 + } 177 + 178 + // --------------------------------------------------------------------------- 179 + // Raw escape hatch — for consumers who need a one-off GraphQL call 180 + // --------------------------------------------------------------------------- 181 + 182 + query<T>(gql: string, variables?: unknown): Promise<T> { 183 + return this.http.execute<T>(gql, variables); 184 + } 185 + }
+32
sdk/typescript/src/errors.ts
··· 1 + export interface GraphQLErrorLocation { 2 + line: number; 3 + column: number; 4 + } 5 + 6 + export interface GraphQLErrorDetail { 7 + message: string; 8 + locations?: GraphQLErrorLocation[]; 9 + path?: (string | number)[]; 10 + extensions?: Record<string, unknown>; 11 + } 12 + 13 + export class RockboxError extends Error { 14 + constructor(message: string, public override readonly cause?: unknown) { 15 + super(message); 16 + this.name = 'RockboxError'; 17 + } 18 + } 19 + 20 + export class RockboxNetworkError extends RockboxError { 21 + constructor(message: string, cause?: unknown) { 22 + super(message, cause); 23 + this.name = 'RockboxNetworkError'; 24 + } 25 + } 26 + 27 + export class RockboxGraphQLError extends RockboxError { 28 + constructor(public readonly errors: GraphQLErrorDetail[]) { 29 + super(errors.map((e) => e.message).join('; ')); 30 + this.name = 'RockboxGraphQLError'; 31 + } 32 + }
+66
sdk/typescript/src/events.ts
··· 1 + import type { Track, Playlist } from './types.js'; 2 + 3 + // --------------------------------------------------------------------------- 4 + // Typed event map — all events the SDK emits 5 + // --------------------------------------------------------------------------- 6 + 7 + export interface RockboxEventMap { 8 + /** Fires whenever the currently playing track changes */ 9 + 'track:changed': Track; 10 + /** Fires whenever the playback status changes (0=stopped, 1=playing, 2=paused) */ 11 + 'status:changed': number; 12 + /** Fires whenever the current playlist changes */ 13 + 'playlist:changed': Playlist; 14 + /** WebSocket connection opened */ 15 + 'ws:open': undefined; 16 + /** WebSocket connection closed */ 17 + 'ws:close': undefined; 18 + /** WebSocket or subscription error */ 19 + 'ws:error': Error; 20 + } 21 + 22 + type EventListener<T> = T extends undefined ? () => void : (payload: T) => void; 23 + 24 + // --------------------------------------------------------------------------- 25 + // Minimal typed EventEmitter — no Node.js dependency 26 + // --------------------------------------------------------------------------- 27 + 28 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 + export class TypedEventEmitter<Events extends Record<string, any>> { 30 + private listeners = new Map<keyof Events, Set<(payload: unknown) => void>>(); 31 + 32 + on<K extends keyof Events>(event: K, listener: EventListener<Events[K]>): this { 33 + if (!this.listeners.has(event)) this.listeners.set(event, new Set()); 34 + this.listeners.get(event)!.add(listener as (payload: unknown) => void); 35 + return this; 36 + } 37 + 38 + once<K extends keyof Events>(event: K, listener: EventListener<Events[K]>): this { 39 + const wrapped = (payload: unknown) => { 40 + this.off(event, wrapped as EventListener<Events[K]>); 41 + (listener as (payload: unknown) => void)(payload); 42 + }; 43 + return this.on(event, wrapped as EventListener<Events[K]>); 44 + } 45 + 46 + off<K extends keyof Events>(event: K, listener: EventListener<Events[K]>): this { 47 + this.listeners.get(event)?.delete(listener as (payload: unknown) => void); 48 + return this; 49 + } 50 + 51 + emit<K extends keyof Events>( 52 + event: K, 53 + ...args: Events[K] extends undefined ? [] : [Events[K]] 54 + ): void { 55 + this.listeners.get(event)?.forEach((fn) => fn(args[0])); 56 + } 57 + 58 + removeAllListeners(event?: keyof Events): this { 59 + if (event) { 60 + this.listeners.delete(event); 61 + } else { 62 + this.listeners.clear(); 63 + } 64 + return this; 65 + } 66 + }
+41
sdk/typescript/src/index.ts
··· 1 + export { RockboxClient } from './client.js'; 2 + export type { RockboxClientConfig } from './client.js'; 3 + 4 + export type { RockboxEventMap } from './events.js'; 5 + export { TypedEventEmitter } from './events.js'; 6 + 7 + export type { RockboxPlugin, PluginContext } from './plugin.js'; 8 + 9 + export { RockboxError, RockboxNetworkError, RockboxGraphQLError } from './errors.js'; 10 + 11 + export { 12 + PlaybackStatus, 13 + RepeatMode, 14 + ChannelConfig, 15 + ReplaygainType, 16 + InsertPosition, 17 + isDirectory, 18 + } from './types.js'; 19 + 20 + export type { 21 + Track, 22 + Album, 23 + Artist, 24 + SearchResults, 25 + Playlist, 26 + SavedPlaylist, 27 + SavedPlaylistFolder, 28 + SmartPlaylist, 29 + TrackStats, 30 + Device, 31 + Entry, 32 + SystemStatus, 33 + UserSettings, 34 + PartialUserSettings, 35 + EqBandSetting, 36 + ReplaygainSettings, 37 + CompressorSettings, 38 + } from './types.js'; 39 + 40 + export type { CreatePlaylistInput, UpdatePlaylistInput } from './api/saved-playlists.js'; 41 + export type { CreateSmartPlaylistInput, UpdateSmartPlaylistInput } from './api/smart-playlists.js';
+48
sdk/typescript/src/plugin.ts
··· 1 + import type { TypedEventEmitter, RockboxEventMap } from './events.js'; 2 + 3 + // --------------------------------------------------------------------------- 4 + // Plugin system — inspired by Jellyfin's IPlugin / Kodi addon architecture 5 + // --------------------------------------------------------------------------- 6 + 7 + export interface PluginContext { 8 + /** HTTP + WebSocket transport — use to issue custom GraphQL operations */ 9 + query<T>(gql: string, variables?: unknown): Promise<T>; 10 + /** Subscribe to typed SDK events */ 11 + events: TypedEventEmitter<RockboxEventMap>; 12 + } 13 + 14 + export interface RockboxPlugin { 15 + /** Unique plugin identifier, e.g. "scrobbler" */ 16 + readonly name: string; 17 + readonly version: string; 18 + readonly description?: string; 19 + install(context: PluginContext): void | Promise<void>; 20 + uninstall?(): void | Promise<void>; 21 + } 22 + 23 + export class PluginRegistry { 24 + private installed = new Map<string, RockboxPlugin>(); 25 + 26 + async register(plugin: RockboxPlugin, context: PluginContext): Promise<void> { 27 + if (this.installed.has(plugin.name)) { 28 + throw new Error(`Plugin "${plugin.name}" is already installed`); 29 + } 30 + await plugin.install(context); 31 + this.installed.set(plugin.name, plugin); 32 + } 33 + 34 + async unregister(name: string): Promise<void> { 35 + const plugin = this.installed.get(name); 36 + if (!plugin) return; 37 + await plugin.uninstall?.(); 38 + this.installed.delete(name); 39 + } 40 + 41 + has(name: string): boolean { 42 + return this.installed.has(name); 43 + } 44 + 45 + list(): RockboxPlugin[] { 46 + return [...this.installed.values()]; 47 + } 48 + }
+91
sdk/typescript/src/transport.ts
··· 1 + import { createClient, type Client } from 'graphql-ws'; 2 + import { RockboxGraphQLError, RockboxNetworkError } from './errors.js'; 3 + 4 + export interface TransportConfig { 5 + httpUrl: string; 6 + wsUrl: string; 7 + } 8 + 9 + // --------------------------------------------------------------------------- 10 + // HTTP transport — plain fetch, no extra deps 11 + // --------------------------------------------------------------------------- 12 + 13 + export class HttpTransport { 14 + constructor(private url: string) {} 15 + 16 + async execute<T>( 17 + query: string, 18 + variables?: unknown, 19 + ): Promise<T> { 20 + let res: Response; 21 + try { 22 + res = await fetch(this.url, { 23 + method: 'POST', 24 + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, 25 + body: JSON.stringify({ query, variables }), 26 + }); 27 + } catch (err) { 28 + throw new RockboxNetworkError(`Failed to reach Rockbox at ${this.url}`, err); 29 + } 30 + 31 + if (!res.ok) { 32 + throw new RockboxNetworkError(`HTTP ${res.status} ${res.statusText}`); 33 + } 34 + 35 + const json = (await res.json()) as { data?: T; errors?: unknown[] }; 36 + if (json.errors?.length) { 37 + throw new RockboxGraphQLError(json.errors as never); 38 + } 39 + 40 + return json.data as T; 41 + } 42 + } 43 + 44 + // --------------------------------------------------------------------------- 45 + // WebSocket transport — lazy, auto-reconnecting via graphql-ws 46 + // --------------------------------------------------------------------------- 47 + 48 + export class WsTransport { 49 + private _client: Client | null = null; 50 + private readonly wsUrl: string; 51 + 52 + constructor(wsUrl: string) { 53 + this.wsUrl = wsUrl; 54 + } 55 + 56 + private client(): Client { 57 + if (!this._client) { 58 + this._client = createClient({ 59 + url: this.wsUrl, 60 + retryAttempts: Infinity, 61 + shouldRetry: () => true, 62 + retryWait: (attempt) => 63 + new Promise((resolve) => 64 + setTimeout(resolve, Math.min(1000 * 2 ** attempt, 30_000)), 65 + ), 66 + }); 67 + } 68 + return this._client; 69 + } 70 + 71 + subscribe<T>( 72 + query: string, 73 + variables: unknown, 74 + sink: { 75 + next(result: { data?: T | null }): void; 76 + error(error: unknown): void; 77 + complete(): void; 78 + }, 79 + ): () => void { 80 + // graphql-ws wraps results in FormattedExecutionResult which has the same {data?} shape 81 + return this.client().subscribe<T>( 82 + { query, variables: variables as Record<string, unknown> }, 83 + sink as never, 84 + ); 85 + } 86 + 87 + dispose(): void { 88 + this._client?.dispose(); 89 + this._client = null; 90 + } 91 + }
+282
sdk/typescript/src/types.ts
··· 1 + // --------------------------------------------------------------------------- 2 + // Enums 3 + // --------------------------------------------------------------------------- 4 + 5 + export enum PlaybackStatus { 6 + Stopped = 0, 7 + Playing = 1, 8 + Paused = 2, 9 + } 10 + 11 + export enum RepeatMode { 12 + Off = 0, 13 + All = 1, 14 + One = 2, 15 + Shuffle = 3, 16 + ABRepeat = 4, 17 + } 18 + 19 + export enum ChannelConfig { 20 + Stereo = 0, 21 + StereoNarrow = 1, 22 + Mono = 2, 23 + LeftMix = 3, 24 + RightMix = 4, 25 + Karaoke = 5, 26 + } 27 + 28 + export enum ReplaygainType { 29 + Track = 0, 30 + Album = 1, 31 + Shuffle = 2, 32 + } 33 + 34 + // --------------------------------------------------------------------------- 35 + // Core audio types 36 + // --------------------------------------------------------------------------- 37 + 38 + export interface Track { 39 + id?: string; 40 + title: string; 41 + artist: string; 42 + album: string; 43 + genre: string; 44 + disc: string; 45 + trackString: string; 46 + yearString: string; 47 + composer: string; 48 + comment: string; 49 + albumArtist: string; 50 + grouping: string; 51 + discnum: number; 52 + tracknum: number; 53 + layer: number; 54 + year: number; 55 + bitrate: number; 56 + frequency: number; 57 + filesize: number; 58 + /** Duration in milliseconds */ 59 + length: number; 60 + /** Current playback position in milliseconds */ 61 + elapsed: number; 62 + path: string; 63 + albumId?: string; 64 + artistId?: string; 65 + genreId?: string; 66 + albumArt?: string; 67 + } 68 + 69 + export interface Album { 70 + id: string; 71 + title: string; 72 + artist: string; 73 + year: number; 74 + yearString: string; 75 + albumArt?: string; 76 + md5: string; 77 + artistId: string; 78 + tracks: Track[]; 79 + } 80 + 81 + export interface Artist { 82 + id: string; 83 + name: string; 84 + bio?: string; 85 + image?: string; 86 + tracks: Track[]; 87 + albums: Album[]; 88 + } 89 + 90 + export interface SearchResults { 91 + artists: Artist[]; 92 + albums: Album[]; 93 + tracks: Track[]; 94 + likedTracks: Track[]; 95 + likedAlbums: Album[]; 96 + } 97 + 98 + // --------------------------------------------------------------------------- 99 + // Playlist types 100 + // --------------------------------------------------------------------------- 101 + 102 + export interface Playlist { 103 + amount: number; 104 + index: number; 105 + maxPlaylistSize: number; 106 + firstIndex: number; 107 + lastInsertPos: number; 108 + seed: number; 109 + lastShuffledStart: number; 110 + tracks: Track[]; 111 + } 112 + 113 + export interface SavedPlaylist { 114 + id: string; 115 + name: string; 116 + description?: string; 117 + image?: string; 118 + folderId?: string; 119 + trackCount: number; 120 + createdAt: number; 121 + updatedAt: number; 122 + } 123 + 124 + export interface SavedPlaylistFolder { 125 + id: string; 126 + name: string; 127 + createdAt: number; 128 + updatedAt: number; 129 + } 130 + 131 + export interface SmartPlaylist { 132 + id: string; 133 + name: string; 134 + description?: string; 135 + image?: string; 136 + folderId?: string; 137 + isSystem: boolean; 138 + /** JSON-encoded rules */ 139 + rules: string; 140 + createdAt: number; 141 + updatedAt: number; 142 + } 143 + 144 + export interface TrackStats { 145 + trackId: string; 146 + playCount: number; 147 + skipCount: number; 148 + lastPlayed?: number; 149 + lastSkipped?: number; 150 + updatedAt: number; 151 + } 152 + 153 + // --------------------------------------------------------------------------- 154 + // Device types 155 + // --------------------------------------------------------------------------- 156 + 157 + export interface Device { 158 + id: string; 159 + name: string; 160 + host: string; 161 + ip: string; 162 + port: number; 163 + service: string; 164 + app: string; 165 + isConnected: boolean; 166 + baseUrl?: string; 167 + isCastDevice: boolean; 168 + isSourceDevice: boolean; 169 + isCurrentDevice: boolean; 170 + } 171 + 172 + // --------------------------------------------------------------------------- 173 + // File browser types 174 + // --------------------------------------------------------------------------- 175 + 176 + export interface Entry { 177 + name: string; 178 + /** Bitmask: bit 4 = directory */ 179 + attr: number; 180 + timeWrite: number; 181 + customaction: number; 182 + } 183 + 184 + export function isDirectory(entry: Entry): boolean { 185 + return (entry.attr & 0x10) !== 0; 186 + } 187 + 188 + // --------------------------------------------------------------------------- 189 + // System types 190 + // --------------------------------------------------------------------------- 191 + 192 + export interface SystemStatus { 193 + resumeIndex: number; 194 + resumeCrc32: number; 195 + resumeElapsed: number; 196 + resumeOffset: number; 197 + runtime: number; 198 + topruntime: number; 199 + dircacheSize: number; 200 + lastScreen: number; 201 + viewerIconCount: number; 202 + lastVolumeChange: number; 203 + } 204 + 205 + // --------------------------------------------------------------------------- 206 + // Settings types 207 + // --------------------------------------------------------------------------- 208 + 209 + export interface EqBandSetting { 210 + cutoff: number; 211 + q: number; 212 + gain: number; 213 + } 214 + 215 + export interface ReplaygainSettings { 216 + noclip: boolean; 217 + type: number; 218 + preamp: number; 219 + } 220 + 221 + export interface CompressorSettings { 222 + threshold: number; 223 + makeupGain: number; 224 + ratio: number; 225 + knee: number; 226 + releaseTime: number; 227 + attackTime: number; 228 + } 229 + 230 + export interface UserSettings { 231 + musicDir: string; 232 + volume: number; 233 + balance: number; 234 + bass: number; 235 + treble: number; 236 + channelConfig: number; 237 + stereoWidth: number; 238 + eqEnabled: boolean; 239 + eqPrecut: number; 240 + eqBandSettings: EqBandSetting[]; 241 + replaygainSettings: ReplaygainSettings; 242 + compressorSettings: CompressorSettings; 243 + crossfadeEnabled: number; 244 + crossfadeFadeInDelay: number; 245 + crossfadeFadeInDuration: number; 246 + crossfadeFadeOutDelay: number; 247 + crossfadeFadeOutDuration: number; 248 + crossfadeFadeOutMixmode: number; 249 + crossfeedEnabled: boolean; 250 + crossfeedDirectGain: number; 251 + crossfeedCrossGain: number; 252 + crossfeedHfAttenuation: number; 253 + crossfeedHfCutoff: number; 254 + repeatMode: number; 255 + singleMode: boolean; 256 + partyMode: boolean; 257 + shuffle: boolean; 258 + playerName: string; 259 + [key: string]: unknown; 260 + } 261 + 262 + export type PartialUserSettings = Partial<Omit<UserSettings, 'eqBandSettings' | 'replaygainSettings' | 'compressorSettings'>> & { 263 + eqBandSettings?: EqBandSetting[]; 264 + replaygainSettings?: ReplaygainSettings; 265 + compressorSettings?: CompressorSettings; 266 + }; 267 + 268 + // --------------------------------------------------------------------------- 269 + // Insert position constants (Kodi / Mopidy convention) 270 + // --------------------------------------------------------------------------- 271 + 272 + export const InsertPosition = { 273 + /** After the currently playing track */ 274 + Next: 0, 275 + /** After the last manually inserted track */ 276 + AfterCurrent: 1, 277 + /** At the end of the playlist */ 278 + Last: 2, 279 + /** Replace the entire playlist */ 280 + First: 3, 281 + } as const; 282 + export type InsertPosition = (typeof InsertPosition)[keyof typeof InsertPosition];
+68
sdk/typescript/tests/client.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { RockboxClient } from '../src/client.js'; 3 + 4 + const mockFetch = vi.fn(); 5 + vi.stubGlobal('fetch', mockFetch); 6 + 7 + function makeOk(data: unknown) { 8 + return { 9 + ok: true, 10 + status: 200, 11 + json: () => Promise.resolve({ data }), 12 + } as Response; 13 + } 14 + 15 + describe('RockboxClient', () => { 16 + beforeEach(() => mockFetch.mockReset()); 17 + 18 + it('constructs with defaults (localhost:6062)', () => { 19 + const client = new RockboxClient(); 20 + expect(client).toBeDefined(); 21 + expect(client.playback).toBeDefined(); 22 + expect(client.library).toBeDefined(); 23 + expect(client.playlist).toBeDefined(); 24 + expect(client.savedPlaylists).toBeDefined(); 25 + expect(client.smartPlaylists).toBeDefined(); 26 + expect(client.sound).toBeDefined(); 27 + expect(client.settings).toBeDefined(); 28 + expect(client.system).toBeDefined(); 29 + expect(client.browse).toBeDefined(); 30 + expect(client.devices).toBeDefined(); 31 + }); 32 + 33 + it('constructs with custom host and port', async () => { 34 + mockFetch.mockResolvedValue(makeOk({ rockboxVersion: '4.0' })); 35 + const client = new RockboxClient({ host: '192.168.1.42', port: 7070 }); 36 + await client.system.version(); 37 + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; 38 + expect(url).toBe('http://192.168.1.42:7070/graphql'); 39 + }); 40 + 41 + it('query() is a raw escape hatch that calls the HTTP transport', async () => { 42 + mockFetch.mockResolvedValue(makeOk({ rockboxVersion: '4.0' })); 43 + const client = new RockboxClient(); 44 + const result = await client.query<{ rockboxVersion: string }>('query { rockboxVersion }'); 45 + expect(result.rockboxVersion).toBe('4.0'); 46 + }); 47 + 48 + it('installedPlugins() starts empty', () => { 49 + const client = new RockboxClient(); 50 + expect(client.installedPlugins()).toHaveLength(0); 51 + }); 52 + 53 + it('use() installs a plugin and calls its install hook', async () => { 54 + const client = new RockboxClient(); 55 + const install = vi.fn(); 56 + await client.use({ name: 'test-plugin', version: '0.1', install }); 57 + expect(install).toHaveBeenCalledTimes(1); 58 + expect(client.installedPlugins()).toHaveLength(1); 59 + }); 60 + 61 + it('is an event emitter — on/emit work', () => { 62 + const client = new RockboxClient(); 63 + const handler = vi.fn(); 64 + client.on('status:changed', handler); 65 + client.emit('status:changed', 1); 66 + expect(handler).toHaveBeenCalledWith(1); 67 + }); 68 + });
+61
sdk/typescript/tests/events.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { TypedEventEmitter } from '../src/events.js'; 3 + import type { RockboxEventMap } from '../src/events.js'; 4 + 5 + describe('TypedEventEmitter', () => { 6 + it('calls registered listener with the correct payload', () => { 7 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 8 + const handler = vi.fn(); 9 + emitter.on('status:changed', handler); 10 + emitter.emit('status:changed', 1); 11 + expect(handler).toHaveBeenCalledWith(1); 12 + }); 13 + 14 + it('supports multiple listeners for the same event', () => { 15 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 16 + const a = vi.fn(); 17 + const b = vi.fn(); 18 + emitter.on('status:changed', a).on('status:changed', b); 19 + emitter.emit('status:changed', 2); 20 + expect(a).toHaveBeenCalledWith(2); 21 + expect(b).toHaveBeenCalledWith(2); 22 + }); 23 + 24 + it('removes a listener with off()', () => { 25 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 26 + const handler = vi.fn(); 27 + emitter.on('status:changed', handler); 28 + emitter.off('status:changed', handler); 29 + emitter.emit('status:changed', 1); 30 + expect(handler).not.toHaveBeenCalled(); 31 + }); 32 + 33 + it('once() fires exactly once', () => { 34 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 35 + const handler = vi.fn(); 36 + emitter.once('status:changed', handler); 37 + emitter.emit('status:changed', 1); 38 + emitter.emit('status:changed', 2); 39 + expect(handler).toHaveBeenCalledTimes(1); 40 + expect(handler).toHaveBeenCalledWith(1); 41 + }); 42 + 43 + it('removeAllListeners() clears all handlers for a given event', () => { 44 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 45 + const a = vi.fn(); 46 + const b = vi.fn(); 47 + emitter.on('status:changed', a).on('status:changed', b); 48 + emitter.removeAllListeners('status:changed'); 49 + emitter.emit('status:changed', 1); 50 + expect(a).not.toHaveBeenCalled(); 51 + expect(b).not.toHaveBeenCalled(); 52 + }); 53 + 54 + it('does not call handlers for a different event', () => { 55 + const emitter = new TypedEventEmitter<RockboxEventMap>(); 56 + const handler = vi.fn(); 57 + emitter.on('status:changed', handler); 58 + emitter.emit('ws:close'); 59 + expect(handler).not.toHaveBeenCalled(); 60 + }); 61 + });
+85
sdk/typescript/tests/library.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { LibraryApi } from '../src/api/library.js'; 3 + import type { HttpTransport } from '../src/transport.js'; 4 + 5 + function makeTransport(result: unknown) { 6 + return { execute: vi.fn().mockResolvedValue(result) } as unknown as HttpTransport; 7 + } 8 + 9 + const ALBUM = { 10 + id: 'a1', 11 + title: 'The Wall', 12 + artist: 'Pink Floyd', 13 + year: 1979, 14 + yearString: '1979', 15 + albumArt: null, 16 + md5: 'abc', 17 + artistId: 'ar1', 18 + tracks: [], 19 + }; 20 + 21 + const TRACK = { 22 + id: 't1', 23 + title: 'Hey You', 24 + artist: 'Pink Floyd', 25 + album: 'The Wall', 26 + genre: 'Rock', 27 + disc: '2', 28 + trackString: '5', 29 + yearString: '1979', 30 + composer: 'Roger Waters', 31 + comment: '', 32 + albumArtist: 'Pink Floyd', 33 + grouping: '', 34 + discnum: 2, 35 + tracknum: 5, 36 + layer: 3, 37 + year: 1979, 38 + bitrate: 320, 39 + frequency: 44100, 40 + filesize: 8888888, 41 + length: 270000, 42 + elapsed: 0, 43 + path: '/Music/Pink Floyd/The Wall/CD2/05 Hey You.mp3', 44 + }; 45 + 46 + describe('LibraryApi', () => { 47 + it('returns a list of albums', async () => { 48 + const api = new LibraryApi(makeTransport({ albums: [ALBUM] })); 49 + const albums = await api.albums(); 50 + expect(albums).toHaveLength(1); 51 + expect(albums[0]!.title).toBe('The Wall'); 52 + }); 53 + 54 + it('returns null for unknown album id', async () => { 55 + const api = new LibraryApi(makeTransport({ album: null })); 56 + expect(await api.album('unknown')).toBeNull(); 57 + }); 58 + 59 + it('returns search results', async () => { 60 + const api = new LibraryApi( 61 + makeTransport({ 62 + search: { 63 + artists: [], 64 + albums: [ALBUM], 65 + tracks: [TRACK], 66 + likedTracks: [], 67 + likedAlbums: [], 68 + }, 69 + }), 70 + ); 71 + const results = await api.search('wall'); 72 + expect(results.albums).toHaveLength(1); 73 + expect(results.tracks).toHaveLength(1); 74 + }); 75 + 76 + it('calls likeTrack mutation with the correct id', async () => { 77 + const transport = makeTransport({}); 78 + const api = new LibraryApi(transport); 79 + await api.likeTrack('track-99'); 80 + expect(transport.execute).toHaveBeenCalledWith( 81 + expect.stringContaining('likeTrack'), 82 + expect.objectContaining({ id: 'track-99' }), 83 + ); 84 + }); 85 + });
+102
sdk/typescript/tests/playback.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { PlaybackApi } from '../src/api/playback.js'; 3 + import { PlaybackStatus } from '../src/types.js'; 4 + import type { HttpTransport } from '../src/transport.js'; 5 + 6 + function makeTransport(result: unknown) { 7 + return { execute: vi.fn().mockResolvedValue(result) } as unknown as HttpTransport; 8 + } 9 + 10 + const TRACK = { 11 + id: 'track-1', 12 + title: 'Comfortably Numb', 13 + artist: 'Pink Floyd', 14 + album: 'The Wall', 15 + genre: 'Rock', 16 + disc: '2', 17 + trackString: '27', 18 + yearString: '1979', 19 + composer: 'Roger Waters', 20 + comment: '', 21 + albumArtist: 'Pink Floyd', 22 + grouping: '', 23 + discnum: 2, 24 + tracknum: 27, 25 + layer: 3, 26 + year: 1979, 27 + bitrate: 320, 28 + frequency: 44100, 29 + filesize: 12345678, 30 + length: 382000, 31 + elapsed: 0, 32 + path: '/Music/Pink Floyd/The Wall/CD2/27 Comfortably Numb.mp3', 33 + }; 34 + 35 + describe('PlaybackApi', () => { 36 + describe('status', () => { 37 + it('returns PlaybackStatus.Playing when firmware returns 1', async () => { 38 + const api = new PlaybackApi(makeTransport({ status: 1 })); 39 + expect(await api.status()).toBe(PlaybackStatus.Playing); 40 + }); 41 + 42 + it('returns PlaybackStatus.Stopped when firmware returns 0', async () => { 43 + const api = new PlaybackApi(makeTransport({ status: 0 })); 44 + expect(await api.status()).toBe(PlaybackStatus.Stopped); 45 + }); 46 + }); 47 + 48 + describe('currentTrack', () => { 49 + it('returns the track when one is playing', async () => { 50 + const api = new PlaybackApi(makeTransport({ currentTrack: TRACK })); 51 + const track = await api.currentTrack(); 52 + expect(track?.title).toBe('Comfortably Numb'); 53 + expect(track?.length).toBe(382000); 54 + }); 55 + 56 + it('returns null when nothing is playing', async () => { 57 + const api = new PlaybackApi(makeTransport({ currentTrack: null })); 58 + expect(await api.currentTrack()).toBeNull(); 59 + }); 60 + }); 61 + 62 + describe('transport controls', () => { 63 + it('calls pause mutation', async () => { 64 + const transport = makeTransport({}); 65 + const api = new PlaybackApi(transport); 66 + await api.pause(); 67 + expect(transport.execute).toHaveBeenCalledWith(expect.stringContaining('pause')); 68 + }); 69 + 70 + it('calls seek with the given position', async () => { 71 + const transport = makeTransport({}); 72 + const api = new PlaybackApi(transport); 73 + await api.seek(60_000); 74 + expect(transport.execute).toHaveBeenCalledWith( 75 + expect.stringContaining('fastForwardRewind'), 76 + expect.objectContaining({ newTime: 60_000 }), 77 + ); 78 + }); 79 + }); 80 + 81 + describe('play helpers', () => { 82 + it('calls playAlbum with shuffle option', async () => { 83 + const transport = makeTransport({}); 84 + const api = new PlaybackApi(transport); 85 + await api.playAlbum('album-42', { shuffle: true }); 86 + expect(transport.execute).toHaveBeenCalledWith( 87 + expect.stringContaining('playAlbum'), 88 + expect.objectContaining({ albumId: 'album-42', shuffle: true }), 89 + ); 90 + }); 91 + 92 + it('calls playDirectory with recurse option', async () => { 93 + const transport = makeTransport({}); 94 + const api = new PlaybackApi(transport); 95 + await api.playDirectory('/Music/Jazz', { recurse: true, shuffle: true }); 96 + expect(transport.execute).toHaveBeenCalledWith( 97 + expect.stringContaining('playDirectory'), 98 + expect.objectContaining({ path: '/Music/Jazz', recurse: true }), 99 + ); 100 + }); 101 + }); 102 + });
+62
sdk/typescript/tests/plugin.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { PluginRegistry } from '../src/plugin.js'; 3 + import type { RockboxPlugin, PluginContext } from '../src/plugin.js'; 4 + 5 + function makeContext(): PluginContext { 6 + return { 7 + query: vi.fn(), 8 + events: { 9 + on: vi.fn().mockReturnThis(), 10 + off: vi.fn().mockReturnThis(), 11 + once: vi.fn().mockReturnThis(), 12 + emit: vi.fn(), 13 + removeAllListeners: vi.fn().mockReturnThis(), 14 + } as unknown as PluginContext['events'], 15 + }; 16 + } 17 + 18 + describe('PluginRegistry', () => { 19 + it('installs a plugin and calls install()', async () => { 20 + const registry = new PluginRegistry(); 21 + const install = vi.fn(); 22 + const plugin: RockboxPlugin = { name: 'scrobbler', version: '1.0.0', install }; 23 + await registry.register(plugin, makeContext()); 24 + expect(install).toHaveBeenCalledTimes(1); 25 + expect(registry.has('scrobbler')).toBe(true); 26 + }); 27 + 28 + it('throws if the same plugin is installed twice', async () => { 29 + const registry = new PluginRegistry(); 30 + const plugin: RockboxPlugin = { name: 'dupe', version: '1.0.0', install: vi.fn() }; 31 + await registry.register(plugin, makeContext()); 32 + await expect(registry.register(plugin, makeContext())).rejects.toThrow(/already installed/); 33 + }); 34 + 35 + it('calls uninstall() when unregistering', async () => { 36 + const registry = new PluginRegistry(); 37 + const uninstall = vi.fn(); 38 + const plugin: RockboxPlugin = { 39 + name: 'lyrics', 40 + version: '1.0.0', 41 + install: vi.fn(), 42 + uninstall, 43 + }; 44 + await registry.register(plugin, makeContext()); 45 + await registry.unregister('lyrics'); 46 + expect(uninstall).toHaveBeenCalledTimes(1); 47 + expect(registry.has('lyrics')).toBe(false); 48 + }); 49 + 50 + it('list() returns all installed plugins', async () => { 51 + const registry = new PluginRegistry(); 52 + const ctx = makeContext(); 53 + await registry.register({ name: 'a', version: '1', install: vi.fn() }, ctx); 54 + await registry.register({ name: 'b', version: '1', install: vi.fn() }, ctx); 55 + expect(registry.list().map((p) => p.name)).toEqual(['a', 'b']); 56 + }); 57 + 58 + it('unregistering a non-existent plugin is a no-op', async () => { 59 + const registry = new PluginRegistry(); 60 + await expect(registry.unregister('ghost')).resolves.toBeUndefined(); 61 + }); 62 + });
+59
sdk/typescript/tests/transport.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { HttpTransport } from '../src/transport.js'; 3 + import { RockboxGraphQLError, RockboxNetworkError } from '../src/errors.js'; 4 + 5 + const mockFetch = vi.fn(); 6 + vi.stubGlobal('fetch', mockFetch); 7 + 8 + function makeResponse(body: unknown, status = 200) { 9 + return { 10 + ok: status >= 200 && status < 300, 11 + status, 12 + statusText: status === 200 ? 'OK' : 'Error', 13 + json: () => Promise.resolve(body), 14 + } as Response; 15 + } 16 + 17 + describe('HttpTransport', () => { 18 + const transport = new HttpTransport('http://localhost:6062/graphql'); 19 + 20 + beforeEach(() => { 21 + mockFetch.mockReset(); 22 + }); 23 + 24 + it('returns data on successful response', async () => { 25 + mockFetch.mockResolvedValue(makeResponse({ data: { rockboxVersion: '4.0' } })); 26 + const result = await transport.execute<{ rockboxVersion: string }>('query { rockboxVersion }'); 27 + expect(result).toEqual({ rockboxVersion: '4.0' }); 28 + }); 29 + 30 + it('passes variables as JSON body', async () => { 31 + mockFetch.mockResolvedValue(makeResponse({ data: { album: null } })); 32 + await transport.execute('query Album($id: String!) { album(id: $id) { id } }', { id: '123' }); 33 + const body = JSON.parse((mockFetch.mock.calls[0]![1] as RequestInit).body as string); 34 + expect(body.variables).toEqual({ id: '123' }); 35 + }); 36 + 37 + it('throws RockboxGraphQLError when errors are present', async () => { 38 + mockFetch.mockResolvedValue( 39 + makeResponse({ data: null, errors: [{ message: 'not found' }] }), 40 + ); 41 + await expect(transport.execute('query { albums { id } }')).rejects.toBeInstanceOf( 42 + RockboxGraphQLError, 43 + ); 44 + }); 45 + 46 + it('throws RockboxNetworkError on non-ok HTTP status', async () => { 47 + mockFetch.mockResolvedValue(makeResponse({}, 500)); 48 + await expect(transport.execute('query { albums { id } }')).rejects.toBeInstanceOf( 49 + RockboxNetworkError, 50 + ); 51 + }); 52 + 53 + it('throws RockboxNetworkError when fetch rejects (connection refused)', async () => { 54 + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); 55 + await expect(transport.execute('query { albums { id } }')).rejects.toBeInstanceOf( 56 + RockboxNetworkError, 57 + ); 58 + }); 59 + });
+29
sdk/typescript/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }
+9
webui/rockbox/src/App.tsx
··· 7 7 import FilesPage from "./Containers/Files"; 8 8 import LikesPage from "./Containers/Likes"; 9 9 import SettingsPage from "./Containers/Settings"; 10 + import PlaylistsPage from "./Containers/Playlists"; 11 + import PlaylistDetailsPage from "./Containers/PlaylistDetails"; 12 + import SmartPlaylistDetailsPage from "./Containers/PlaylistDetails/SmartPlaylistDetailsPage"; 10 13 11 14 function App() { 12 15 return ( ··· 21 24 <Route path="/files" element={<FilesPage />} /> 22 25 <Route path="/likes" element={<LikesPage />} /> 23 26 <Route path="/settings" element={<SettingsPage />} /> 27 + <Route path="/playlists" element={<PlaylistsPage />} /> 28 + <Route path="/playlists/:id" element={<PlaylistDetailsPage />} /> 29 + <Route 30 + path="/playlists/smart/:id" 31 + element={<SmartPlaylistDetailsPage />} 32 + /> 24 33 </Routes> 25 34 </BrowserRouter> 26 35 );
+17 -2
webui/rockbox/src/Components/ContextMenu/ContextMenuWithData.tsx
··· 2 2 import ContextMenu from "./ContextMenu"; 3 3 import { 4 4 useGetLikedTracksQuery, 5 + useGetSavedPlaylistsQuery, 5 6 useInsertTracksMutation, 6 7 useLikeTrackMutation, 7 8 useUnlikeTrackMutation, 9 + useAddTracksToSavedPlaylistMutation, 8 10 } from "../../Hooks/GraphQL"; 9 11 import { 10 12 PLAYLIST_INSERT_FIRST, ··· 24 26 const { refetch } = useGetLikedTracksQuery({ 25 27 fetchPolicy: "network-only", 26 28 }); 29 + const { data: playlistsData } = useGetSavedPlaylistsQuery({ 30 + fetchPolicy: "cache-and-network", 31 + }); 27 32 const [insertTracks] = useInsertTracksMutation(); 28 33 const [likeTrack] = useLikeTrackMutation(); 29 34 const [unlikeTrack] = useUnlikeTrackMutation(); 35 + const [addTracksToPlaylist] = useAddTracksToSavedPlaylistMutation(); 30 36 31 37 const onPlayNext = (path: string) => { 32 38 insertTracks({ ··· 81 87 await refetch(); 82 88 }; 83 89 90 + const onAddTrackToPlaylist = (playlistId: string, trackId: string) => { 91 + addTracksToPlaylist({ 92 + variables: { 93 + playlistId, 94 + trackIds: [trackId], 95 + }, 96 + }); 97 + }; 98 + 84 99 return ( 85 100 <ContextMenu 86 101 track={track} 87 102 onPlayNext={onPlayNext} 88 103 onCreatePlaylist={() => {}} 89 - onAddTrackToPlaylist={() => {}} 104 + onAddTrackToPlaylist={onAddTrackToPlaylist} 90 105 onPlayLast={onPlayLast} 91 106 onAddShuffled={onAddShuffled} 92 107 onLike={onLike} 93 108 onUnlike={onUnlike} 94 - recentPlaylists={[]} 109 + recentPlaylists={playlistsData?.savedPlaylists ?? []} 95 110 liked={likes[track.id]} 96 111 /> 97 112 );
+208 -2
webui/rockbox/src/Components/PlaylistDetails/PlaylistDetails.tsx
··· 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 1 2 import { FC } from "react"; 3 + import { createColumnHelper } from "@tanstack/react-table"; 4 + import Sidebar from "../Sidebar"; 5 + import ControlBar from "../ControlBar"; 6 + import MainView from "../MainView/MainView"; 7 + import { 8 + Container, 9 + ContentWrapper, 10 + Header, 11 + CoverArt, 12 + PlaylistInfos, 13 + PlaylistTitle, 14 + PlaylistDescription, 15 + TrackCount, 16 + ButtonGroup, 17 + Separator, 18 + BackButton, 19 + Label, 20 + Link, 21 + } from "./styles"; 22 + import Button from "../Button"; 23 + import ArrowBack from "../Icons/ArrowBack"; 24 + import Play from "../Icons/Play"; 25 + import Shuffle from "../Icons/Shuffle"; 26 + import Table from "../Table"; 27 + import { Track } from "../../Types/track"; 28 + import ContextMenu from "../ContextMenu"; 29 + import { Music } from "@styled-icons/boxicons-regular"; 2 30 3 - const PlaylistDetails: FC = () => { 4 - return <></>; 31 + const columnHelper = createColumnHelper<Track>(); 32 + 33 + export type PlaylistDetailsProps = { 34 + playlist?: any; 35 + tracks: Track[]; 36 + isSmart?: boolean; 37 + onGoBack: () => void; 38 + onPlayAll: () => void; 39 + onShuffleAll: () => void; 40 + onPlayTrack: (position: number) => void; 41 + onRemoveTrack?: (trackId: string) => void; 42 + }; 43 + 44 + const PlaylistDetails: FC<PlaylistDetailsProps> = ({ 45 + playlist, 46 + tracks, 47 + isSmart, 48 + onGoBack, 49 + onPlayAll, 50 + onShuffleAll, 51 + onPlayTrack, 52 + onRemoveTrack, 53 + }) => { 54 + const columns = [ 55 + columnHelper.accessor("trackNumber", { 56 + header: "#", 57 + size: 20, 58 + cell: (info) => ( 59 + <div style={{ position: "relative" }}> 60 + <div className="tracknumber">{info.row.index + 1}</div> 61 + <div 62 + className="floating-play" 63 + onClick={() => onPlayTrack(info.row.index)} 64 + > 65 + <Play color="#000" small /> 66 + </div> 67 + </div> 68 + ), 69 + }), 70 + columnHelper.accessor("title", { 71 + header: "Title", 72 + cell: (info) => ( 73 + <div 74 + style={{ 75 + minWidth: 150, 76 + width: "calc(100% - 20px)", 77 + maxWidth: "300px", 78 + fontSize: 14, 79 + textOverflow: "ellipsis", 80 + overflow: "hidden", 81 + whiteSpace: "nowrap", 82 + cursor: "pointer", 83 + color: "#000", 84 + }} 85 + > 86 + {info.getValue()} 87 + </div> 88 + ), 89 + }), 90 + columnHelper.accessor("artist", { 91 + header: "Artist", 92 + cell: (info) => ( 93 + <div 94 + style={{ 95 + minWidth: 150, 96 + width: "calc(100% - 20px)", 97 + maxWidth: "calc(100vw - 800px)", 98 + fontSize: 14, 99 + overflow: "hidden", 100 + textOverflow: "ellipsis", 101 + whiteSpace: "nowrap", 102 + cursor: "pointer", 103 + color: "#000", 104 + }} 105 + > 106 + <Link to={`/artists/${info.row.original.artistId}`}> 107 + {info.getValue()} 108 + </Link> 109 + </div> 110 + ), 111 + }), 112 + columnHelper.accessor("time", { 113 + header: "Time", 114 + size: 50, 115 + cell: (info) => info.getValue(), 116 + }), 117 + columnHelper.accessor("id", { 118 + header: "", 119 + size: 120, 120 + cell: (info) => ( 121 + <div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8 }}> 122 + {!isSmart && onRemoveTrack && ( 123 + <button 124 + onClick={() => onRemoveTrack(info.row.original.id)} 125 + style={{ 126 + background: "transparent", 127 + border: "none", 128 + cursor: "pointer", 129 + color: "#aaa", 130 + fontSize: 12, 131 + padding: "2px 6px", 132 + }} 133 + title="Remove from playlist" 134 + > 135 + Remove 136 + </button> 137 + )} 138 + <ContextMenu 139 + track={{ 140 + id: info.row.original.id, 141 + title: info.row.original.title, 142 + artist: info.row.original.artist, 143 + time: info.row.original.time, 144 + cover: info.row.original.albumArt, 145 + path: info.row.original.path, 146 + }} 147 + /> 148 + </div> 149 + ), 150 + }), 151 + ]; 152 + 153 + return ( 154 + <Container> 155 + <Sidebar active="playlists" /> 156 + <MainView> 157 + <ControlBar /> 158 + <ContentWrapper> 159 + <BackButton onClick={onGoBack}> 160 + <div style={{ marginTop: 2 }}> 161 + <ArrowBack color="#000" /> 162 + </div> 163 + </BackButton> 164 + <div style={{ marginBottom: 100 }}> 165 + <Header> 166 + <CoverArt image={playlist?.image}> 167 + {!playlist?.image && <Music size={64} color="#bbb" />} 168 + </CoverArt> 169 + <PlaylistInfos> 170 + <div 171 + style={{ 172 + display: "flex", 173 + flexDirection: "column", 174 + justifyContent: "center", 175 + height: "calc(240px - 12px)", 176 + }} 177 + > 178 + <PlaylistTitle>{playlist?.name}</PlaylistTitle> 179 + {playlist?.description && ( 180 + <PlaylistDescription> 181 + {playlist.description} 182 + </PlaylistDescription> 183 + )} 184 + <TrackCount>{tracks.length} TRACKS</TrackCount> 185 + </div> 186 + <ButtonGroup> 187 + <Button onClick={onPlayAll} kind="primary"> 188 + <Label> 189 + <Play small color="#fff" /> 190 + <div style={{ marginLeft: 7 }}>Play</div> 191 + </Label> 192 + </Button> 193 + <Separator /> 194 + <Button onClick={onShuffleAll} kind="secondary"> 195 + <Label> 196 + <Shuffle color="#fe099c" /> 197 + <div style={{ marginLeft: 7 }}>Shuffle</div> 198 + </Label> 199 + </Button> 200 + </ButtonGroup> 201 + </PlaylistInfos> 202 + </Header> 203 + {tracks.length > 0 && ( 204 + <Table columns={columns as any} tracks={tracks} /> 205 + )} 206 + </div> 207 + </ContentWrapper> 208 + </MainView> 209 + </Container> 210 + ); 5 211 }; 6 212 7 213 export default PlaylistDetails;
+130
webui/rockbox/src/Components/PlaylistDetails/PlaylistDetailsWithData.tsx
··· 1 + import { FC, useEffect, useMemo, useState } from "react"; 2 + import { useNavigate, useParams } from "react-router-dom"; 3 + import { 4 + useGetSavedPlaylistQuery, 5 + useGetSavedPlaylistTracksQuery, 6 + useGetSmartPlaylistQuery, 7 + useGetSmartPlaylistTracksQuery, 8 + usePlaySavedPlaylistMutation, 9 + usePlaySmartPlaylistMutation, 10 + useRemoveTrackFromSavedPlaylistMutation, 11 + useInsertTracksMutation, 12 + } from "../../Hooks/GraphQL"; 13 + import { useTimeFormat } from "../../Hooks/useFormat"; 14 + import { Track } from "../../Types/track"; 15 + import { PLAYLIST_INSERT_FIRST } from "../../Types/playlist"; 16 + import PlaylistDetails from "./PlaylistDetails"; 17 + 18 + type Props = { isSmart?: boolean }; 19 + 20 + const PlaylistDetailsWithData: FC<Props> = ({ isSmart = false }) => { 21 + const { id } = useParams<{ id: string }>(); 22 + const navigate = useNavigate(); 23 + const { formatTime } = useTimeFormat(); 24 + const [tracks, setTracks] = useState<Track[]>([]); 25 + 26 + const { data: savedData } = useGetSavedPlaylistQuery({ 27 + variables: { id: id! }, 28 + skip: isSmart, 29 + }); 30 + const { data: savedTracksData, refetch: refetchTracks } = 31 + useGetSavedPlaylistTracksQuery({ 32 + variables: { playlistId: id! }, 33 + skip: isSmart, 34 + }); 35 + 36 + const { data: smartData } = useGetSmartPlaylistQuery({ 37 + variables: { id: id! }, 38 + skip: !isSmart, 39 + }); 40 + const { data: smartTracksData } = useGetSmartPlaylistTracksQuery({ 41 + variables: { id: id! }, 42 + skip: !isSmart, 43 + }); 44 + 45 + const [playSaved] = usePlaySavedPlaylistMutation(); 46 + const [playSmart] = usePlaySmartPlaylistMutation(); 47 + const [removeTrack] = useRemoveTrackFromSavedPlaylistMutation(); 48 + const [insertTracks] = useInsertTracksMutation(); 49 + 50 + const playlist = useMemo( 51 + () => (isSmart ? smartData?.smartPlaylist : savedData?.savedPlaylist), 52 + [isSmart, savedData, smartData] 53 + ); 54 + 55 + const rawTracks = useMemo( 56 + () => 57 + isSmart 58 + ? smartTracksData?.smartPlaylistTracks 59 + : savedTracksData?.savedPlaylistTracks, 60 + [isSmart, savedTracksData, smartTracksData] 61 + ); 62 + 63 + useEffect(() => { 64 + if (!rawTracks) return; 65 + setTracks( 66 + rawTracks.map((t, i) => ({ 67 + id: t.id ?? "", 68 + trackNumber: i + 1, 69 + title: t.title, 70 + artist: t.artist, 71 + artistId: t.artistId ?? undefined, 72 + albumId: t.albumId ?? undefined, 73 + time: formatTime(t.length), 74 + albumArt: t.albumArt ?? undefined, 75 + path: t.path, 76 + })) 77 + ); 78 + // eslint-disable-next-line react-hooks/exhaustive-deps 79 + }, [rawTracks]); 80 + 81 + function onPlayAll(shuffle = false) { 82 + if (isSmart) { 83 + playSmart({ variables: { id: id! } }); 84 + } else { 85 + if (shuffle) { 86 + const paths = tracks.map((t) => t.path); 87 + const shuffled = [...paths].sort(() => Math.random() - 0.5); 88 + insertTracks({ 89 + variables: { position: PLAYLIST_INSERT_FIRST, tracks: shuffled }, 90 + }); 91 + } else { 92 + playSaved({ variables: { playlistId: id! } }); 93 + } 94 + } 95 + } 96 + 97 + function onPlayTrack(position: number) { 98 + if (isSmart) { 99 + playSmart({ variables: { id: id! } }); 100 + } else { 101 + const paths = tracks.map((t) => t.path); 102 + const ordered = [...paths.slice(position), ...paths.slice(0, position)]; 103 + insertTracks({ 104 + variables: { position: PLAYLIST_INSERT_FIRST, tracks: ordered }, 105 + }); 106 + } 107 + } 108 + 109 + async function onRemoveTrack(trackId: string) { 110 + await removeTrack({ 111 + variables: { playlistId: id!, trackId }, 112 + }); 113 + await refetchTracks(); 114 + } 115 + 116 + return ( 117 + <PlaylistDetails 118 + playlist={playlist} 119 + tracks={tracks} 120 + isSmart={isSmart} 121 + onGoBack={() => navigate(-1)} 122 + onPlayAll={() => onPlayAll(false)} 123 + onShuffleAll={() => onPlayAll(true)} 124 + onPlayTrack={onPlayTrack} 125 + onRemoveTrack={!isSmart ? onRemoveTrack : undefined} 126 + /> 127 + ); 128 + }; 129 + 130 + export default PlaylistDetailsWithData;
+1 -3
webui/rockbox/src/Components/PlaylistDetails/index.tsx
··· 1 - import PlaylistDetails from "./PlaylistDetails"; 2 - 3 - export default PlaylistDetails; 1 + export { default } from "./PlaylistDetailsWithData";
+105
webui/rockbox/src/Components/PlaylistDetails/styles.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { Link as RouterLink } from "react-router-dom"; 3 + 4 + export const Container = styled.div` 5 + display: flex; 6 + flex-direction: row; 7 + width: 100%; 8 + height: 100%; 9 + `; 10 + 11 + export const ContentWrapper = styled.div` 12 + padding-left: 30px; 13 + padding-right: 30px; 14 + overflow-y: auto; 15 + height: calc(100vh - 60px); 16 + `; 17 + 18 + export const Header = styled.div` 19 + display: flex; 20 + flex-direction: row; 21 + align-items: center; 22 + margin-bottom: 20px; 23 + margin-top: 90px; 24 + `; 25 + 26 + export const CoverArt = styled.div<{ image?: string }>` 27 + height: 240px; 28 + width: 240px; 29 + border-radius: 6px; 30 + background-color: #e8e8e8; 31 + background-image: ${({ image }) => (image ? `url(${image})` : "none")}; 32 + background-size: cover; 33 + background-position: center; 34 + display: flex; 35 + align-items: center; 36 + justify-content: center; 37 + flex-shrink: 0; 38 + `; 39 + 40 + export const PlaylistInfos = styled.div` 41 + display: flex; 42 + flex-direction: column; 43 + margin-left: 26px; 44 + height: 240px; 45 + justify-content: center; 46 + `; 47 + 48 + export const PlaylistTitle = styled.div` 49 + font-size: 32px; 50 + font-family: RockfordSansBold; 51 + `; 52 + 53 + export const PlaylistDescription = styled.div` 54 + font-size: 14px; 55 + color: #555; 56 + margin-top: 8px; 57 + `; 58 + 59 + export const TrackCount = styled.div` 60 + margin-top: 25px; 61 + font-weight: 400; 62 + font-size: 14px; 63 + `; 64 + 65 + export const ButtonGroup = styled.div` 66 + display: flex; 67 + flex-direction: row; 68 + align-items: center; 69 + margin-top: 20px; 70 + `; 71 + 72 + export const Separator = styled.div` 73 + width: 20px; 74 + `; 75 + 76 + export const BackButton = styled.button` 77 + border: none; 78 + cursor: pointer; 79 + display: flex; 80 + align-items: center; 81 + justify-content: center; 82 + height: 30px; 83 + width: 30px; 84 + border-radius: 15px; 85 + background-color: #f7f7f8; 86 + margin-top: 26px; 87 + margin-bottom: 46px; 88 + position: absolute; 89 + z-index: 1; 90 + `; 91 + 92 + export const Label = styled.div` 93 + display: flex; 94 + flex-direction: row; 95 + align-items: center; 96 + `; 97 + 98 + export const Link = styled(RouterLink)` 99 + color: #000; 100 + text-decoration: none; 101 + font-family: RockfordSansRegular; 102 + &:hover { 103 + text-decoration: underline; 104 + } 105 + `;
+148
webui/rockbox/src/Components/Playlists/PlaylistModal.tsx
··· 1 + import { FC, useState } from "react"; 2 + 3 + type PlaylistModalProps = { 4 + title: string; 5 + initialName?: string; 6 + initialDescription?: string; 7 + onClose: () => void; 8 + onSave: (name: string, description?: string) => Promise<void>; 9 + }; 10 + 11 + const PlaylistModal: FC<PlaylistModalProps> = ({ 12 + title, 13 + initialName = "", 14 + initialDescription = "", 15 + onClose, 16 + onSave, 17 + }) => { 18 + const [name, setName] = useState(initialName); 19 + const [description, setDescription] = useState(initialDescription); 20 + const [saving, setSaving] = useState(false); 21 + 22 + async function handleSave() { 23 + if (!name.trim()) return; 24 + setSaving(true); 25 + await onSave(name.trim(), description.trim() || undefined); 26 + setSaving(false); 27 + } 28 + 29 + return ( 30 + <div 31 + style={{ 32 + position: "fixed", 33 + inset: 0, 34 + background: "rgba(0,0,0,0.4)", 35 + display: "flex", 36 + alignItems: "center", 37 + justifyContent: "center", 38 + zIndex: 1000, 39 + }} 40 + onClick={onClose} 41 + > 42 + <div 43 + style={{ 44 + background: "#fff", 45 + borderRadius: 12, 46 + padding: 28, 47 + width: 380, 48 + boxShadow: "0 8px 32px rgba(0,0,0,0.15)", 49 + }} 50 + onClick={(e) => e.stopPropagation()} 51 + > 52 + <div 53 + style={{ 54 + fontSize: 18, 55 + fontFamily: "RockfordSansMedium", 56 + marginBottom: 20, 57 + }} 58 + > 59 + {title} 60 + </div> 61 + <div style={{ marginBottom: 14 }}> 62 + <label 63 + style={{ 64 + fontSize: 12, 65 + color: "#555", 66 + display: "block", 67 + marginBottom: 4, 68 + }} 69 + > 70 + Name 71 + </label> 72 + <input 73 + autoFocus 74 + value={name} 75 + onChange={(e) => setName(e.target.value)} 76 + onKeyDown={(e) => e.key === "Enter" && handleSave()} 77 + style={{ 78 + width: "100%", 79 + border: "1px solid #ddd", 80 + borderRadius: 8, 81 + padding: "8px 10px", 82 + fontSize: 14, 83 + outline: "none", 84 + boxSizing: "border-box", 85 + }} 86 + /> 87 + </div> 88 + <div style={{ marginBottom: 24 }}> 89 + <label 90 + style={{ 91 + fontSize: 12, 92 + color: "#555", 93 + display: "block", 94 + marginBottom: 4, 95 + }} 96 + > 97 + Description (optional) 98 + </label> 99 + <input 100 + value={description} 101 + onChange={(e) => setDescription(e.target.value)} 102 + style={{ 103 + width: "100%", 104 + border: "1px solid #ddd", 105 + borderRadius: 8, 106 + padding: "8px 10px", 107 + fontSize: 14, 108 + outline: "none", 109 + boxSizing: "border-box", 110 + }} 111 + /> 112 + </div> 113 + <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}> 114 + <button 115 + onClick={onClose} 116 + style={{ 117 + border: "1px solid #ddd", 118 + borderRadius: 8, 119 + padding: "8px 16px", 120 + cursor: "pointer", 121 + background: "#fff", 122 + fontSize: 13, 123 + }} 124 + > 125 + Cancel 126 + </button> 127 + <button 128 + onClick={handleSave} 129 + disabled={!name.trim() || saving} 130 + style={{ 131 + background: name.trim() ? "#fe099c" : "#ccc", 132 + color: "#fff", 133 + border: "none", 134 + borderRadius: 8, 135 + padding: "8px 16px", 136 + cursor: name.trim() ? "pointer" : "default", 137 + fontSize: 13, 138 + }} 139 + > 140 + {saving ? "Saving..." : "Save"} 141 + </button> 142 + </div> 143 + </div> 144 + </div> 145 + ); 146 + }; 147 + 148 + export default PlaylistModal;
+251 -3
webui/rockbox/src/Components/Playlists/Playlists.tsx
··· 1 - import { FC } from "react"; 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 2 + import { FC, useState } from "react"; 3 + import MainView from "../MainView"; 4 + import Sidebar from "../Sidebar"; 5 + import ControlBar from "../ControlBar"; 6 + import { 7 + Container, 8 + Scrollable, 9 + Title, 10 + SectionTitle, 11 + PlaylistGrid, 12 + PlaylistCard, 13 + PlaylistCover, 14 + PlaylistName, 15 + PlaylistMeta, 16 + CardActions, 17 + CardAction, 18 + Link, 19 + } from "./styles"; 20 + import Play from "../Icons/Play"; 21 + import { Music } from "@styled-icons/boxicons-regular"; 22 + import { Edit2, Trash2 } from "@styled-icons/feather"; 23 + import PlaylistModal from "./PlaylistModal"; 24 + 25 + export type PlaylistsProps = { 26 + savedPlaylists: any[]; 27 + smartPlaylists: any[]; 28 + onPlay: (id: string, isSmart: boolean) => void; 29 + onEdit: (playlist: any) => void; 30 + onDelete: (id: string) => void; 31 + onCreate: (name: string, description?: string) => Promise<void>; 32 + onUpdate: (id: string, name: string, description?: string) => Promise<void>; 33 + }; 34 + 35 + const Playlists: FC<PlaylistsProps> = ({ 36 + savedPlaylists, 37 + smartPlaylists, 38 + onPlay, 39 + onDelete, 40 + onCreate, 41 + onUpdate, 42 + }) => { 43 + const [createModalOpen, setCreateModalOpen] = useState(false); 44 + const [editPlaylist, setEditPlaylist] = useState<any>(null); 45 + const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); 46 + 47 + return ( 48 + <Container> 49 + <Sidebar active="playlists" /> 50 + <MainView> 51 + <ControlBar /> 52 + <Scrollable> 53 + <div 54 + style={{ 55 + display: "flex", 56 + alignItems: "center", 57 + justifyContent: "space-between", 58 + paddingRight: 20, 59 + }} 60 + > 61 + <Title>Playlists</Title> 62 + <button 63 + onClick={() => setCreateModalOpen(true)} 64 + style={{ 65 + background: "#fe099c", 66 + color: "#fff", 67 + border: "none", 68 + borderRadius: 20, 69 + padding: "8px 18px", 70 + fontSize: 13, 71 + cursor: "pointer", 72 + fontFamily: "RockfordSansMedium", 73 + }} 74 + > 75 + + New Playlist 76 + </button> 77 + </div> 78 + 79 + {savedPlaylists.length > 0 && ( 80 + <> 81 + <SectionTitle>MY PLAYLISTS</SectionTitle> 82 + <PlaylistGrid> 83 + {savedPlaylists.map((playlist) => ( 84 + <PlaylistCard key={playlist.id}> 85 + <Link to={`/playlists/${playlist.id}`}> 86 + <PlaylistCover image={playlist.image}> 87 + {!playlist.image && <Music size={48} color="#bbb" />} 88 + </PlaylistCover> 89 + </Link> 90 + <CardActions className="card-actions"> 91 + <CardAction onClick={() => onPlay(playlist.id, false)}> 92 + <Play small color="#000" /> 93 + </CardAction> 94 + <div style={{ display: "flex", gap: 4 }}> 95 + <CardAction onClick={() => setEditPlaylist(playlist)}> 96 + <Edit2 size={15} color="#000" /> 97 + </CardAction> 98 + <CardAction 99 + onClick={() => setDeleteConfirmId(playlist.id)} 100 + > 101 + <Trash2 size={15} color="#e00" /> 102 + </CardAction> 103 + </div> 104 + </CardActions> 105 + <Link to={`/playlists/${playlist.id}`}> 106 + <PlaylistName>{playlist.name}</PlaylistName> 107 + </Link> 108 + <PlaylistMeta>{playlist.trackCount} tracks</PlaylistMeta> 109 + </PlaylistCard> 110 + ))} 111 + </PlaylistGrid> 112 + </> 113 + )} 2 114 3 - const Playlists: FC = () => { 4 - return <></>; 115 + {smartPlaylists.length > 0 && ( 116 + <> 117 + <SectionTitle>SMART PLAYLISTS</SectionTitle> 118 + <PlaylistGrid> 119 + {smartPlaylists.map((playlist) => ( 120 + <PlaylistCard key={playlist.id}> 121 + <Link to={`/playlists/smart/${playlist.id}`}> 122 + <PlaylistCover> 123 + <Music size={48} color="#fe099c" /> 124 + </PlaylistCover> 125 + </Link> 126 + <CardActions className="card-actions"> 127 + <CardAction onClick={() => onPlay(playlist.id, true)}> 128 + <Play small color="#000" /> 129 + </CardAction> 130 + </CardActions> 131 + <Link to={`/playlists/smart/${playlist.id}`}> 132 + <PlaylistName>{playlist.name}</PlaylistName> 133 + </Link> 134 + {playlist.description && ( 135 + <PlaylistMeta>{playlist.description}</PlaylistMeta> 136 + )} 137 + </PlaylistCard> 138 + ))} 139 + </PlaylistGrid> 140 + </> 141 + )} 142 + 143 + {savedPlaylists.length === 0 && smartPlaylists.length === 0 && ( 144 + <div style={{ padding: "40px 20px", color: "#888", fontSize: 14 }}> 145 + No playlists yet. Create one to get started. 146 + </div> 147 + )} 148 + </Scrollable> 149 + </MainView> 150 + 151 + {createModalOpen && ( 152 + <PlaylistModal 153 + title="New Playlist" 154 + onClose={() => setCreateModalOpen(false)} 155 + onSave={async (name, description) => { 156 + await onCreate(name, description); 157 + setCreateModalOpen(false); 158 + }} 159 + /> 160 + )} 161 + 162 + {editPlaylist && ( 163 + <PlaylistModal 164 + title="Edit Playlist" 165 + initialName={editPlaylist.name} 166 + initialDescription={editPlaylist.description} 167 + onClose={() => setEditPlaylist(null)} 168 + onSave={async (name, description) => { 169 + await onUpdate(editPlaylist.id, name, description); 170 + setEditPlaylist(null); 171 + }} 172 + /> 173 + )} 174 + 175 + {deleteConfirmId && ( 176 + <div 177 + style={{ 178 + position: "fixed", 179 + inset: 0, 180 + background: "rgba(0,0,0,0.4)", 181 + display: "flex", 182 + alignItems: "center", 183 + justifyContent: "center", 184 + zIndex: 1000, 185 + }} 186 + onClick={() => setDeleteConfirmId(null)} 187 + > 188 + <div 189 + style={{ 190 + background: "#fff", 191 + borderRadius: 12, 192 + padding: 28, 193 + width: 320, 194 + boxShadow: "0 8px 32px rgba(0,0,0,0.15)", 195 + }} 196 + onClick={(e) => e.stopPropagation()} 197 + > 198 + <div 199 + style={{ 200 + fontSize: 16, 201 + fontFamily: "RockfordSansMedium", 202 + marginBottom: 12, 203 + }} 204 + > 205 + Delete playlist? 206 + </div> 207 + <div style={{ fontSize: 14, color: "#555", marginBottom: 24 }}> 208 + This action cannot be undone. 209 + </div> 210 + <div 211 + style={{ 212 + display: "flex", 213 + gap: 10, 214 + justifyContent: "flex-end", 215 + }} 216 + > 217 + <button 218 + onClick={() => setDeleteConfirmId(null)} 219 + style={{ 220 + border: "1px solid #ddd", 221 + borderRadius: 8, 222 + padding: "8px 16px", 223 + cursor: "pointer", 224 + background: "#fff", 225 + fontSize: 13, 226 + }} 227 + > 228 + Cancel 229 + </button> 230 + <button 231 + onClick={() => { 232 + onDelete(deleteConfirmId); 233 + setDeleteConfirmId(null); 234 + }} 235 + style={{ 236 + background: "#e00", 237 + color: "#fff", 238 + border: "none", 239 + borderRadius: 8, 240 + padding: "8px 16px", 241 + cursor: "pointer", 242 + fontSize: 13, 243 + }} 244 + > 245 + Delete 246 + </button> 247 + </div> 248 + </div> 249 + </div> 250 + )} 251 + </Container> 252 + ); 5 253 }; 6 254 7 255 export default Playlists;
+63
webui/rockbox/src/Components/Playlists/PlaylistsWithData.tsx
··· 1 + import { FC } from "react"; 2 + import Playlists from "./Playlists"; 3 + import { 4 + useGetSavedPlaylistsQuery, 5 + useGetSmartPlaylistsQuery, 6 + useCreateSavedPlaylistMutation, 7 + useUpdateSavedPlaylistMutation, 8 + useDeleteSavedPlaylistMutation, 9 + usePlaySavedPlaylistMutation, 10 + usePlaySmartPlaylistMutation, 11 + } from "../../Hooks/GraphQL"; 12 + 13 + const PlaylistsWithData: FC = () => { 14 + const { data: savedData, refetch: refetchSaved } = useGetSavedPlaylistsQuery({ 15 + fetchPolicy: "cache-and-network", 16 + }); 17 + const { data: smartData } = useGetSmartPlaylistsQuery({ 18 + fetchPolicy: "cache-and-network", 19 + }); 20 + 21 + const [createPlaylist] = useCreateSavedPlaylistMutation(); 22 + const [updatePlaylist] = useUpdateSavedPlaylistMutation(); 23 + const [deletePlaylist] = useDeleteSavedPlaylistMutation(); 24 + const [playSaved] = usePlaySavedPlaylistMutation(); 25 + const [playSmart] = usePlaySmartPlaylistMutation(); 26 + 27 + async function onCreate(name: string, description?: string) { 28 + await createPlaylist({ variables: { name, description } }); 29 + await refetchSaved(); 30 + } 31 + 32 + async function onUpdate(id: string, name: string, description?: string) { 33 + await updatePlaylist({ variables: { id, name, description } }); 34 + await refetchSaved(); 35 + } 36 + 37 + async function onDelete(id: string) { 38 + await deletePlaylist({ variables: { id } }); 39 + await refetchSaved(); 40 + } 41 + 42 + function onPlay(id: string, isSmart: boolean) { 43 + if (isSmart) { 44 + playSmart({ variables: { id } }); 45 + } else { 46 + playSaved({ variables: { playlistId: id } }); 47 + } 48 + } 49 + 50 + return ( 51 + <Playlists 52 + savedPlaylists={savedData?.savedPlaylists ?? []} 53 + smartPlaylists={smartData?.smartPlaylists ?? []} 54 + onPlay={onPlay} 55 + onEdit={() => {}} 56 + onDelete={onDelete} 57 + onCreate={onCreate} 58 + onUpdate={onUpdate} 59 + /> 60 + ); 61 + }; 62 + 63 + export default PlaylistsWithData;
+1 -3
webui/rockbox/src/Components/Playlists/index.tsx
··· 1 - import Playlists from "./Playlists"; 2 - 3 - export default Playlists; 1 + export { default } from "./PlaylistsWithData";
+116
webui/rockbox/src/Components/Playlists/styles.tsx
··· 1 + import styled from "@emotion/styled"; 2 + import { Link as RouterLink } from "react-router-dom"; 3 + 4 + export const Container = styled.div` 5 + display: flex; 6 + flex-direction: row; 7 + width: 100%; 8 + height: 100%; 9 + `; 10 + 11 + export const Scrollable = styled.div` 12 + height: calc(100vh - 60px); 13 + overflow-y: auto; 14 + `; 15 + 16 + export const Title = styled.div` 17 + font-size: 24px; 18 + font-family: RockfordSansMedium; 19 + max-width: 96%; 20 + margin: auto; 21 + margin-bottom: 20px; 22 + padding-left: 20px; 23 + padding-right: 20px; 24 + `; 25 + 26 + export const SectionTitle = styled.div` 27 + font-size: 16px; 28 + font-family: RockfordSansMedium; 29 + padding-left: 20px; 30 + padding-right: 20px; 31 + margin-bottom: 16px; 32 + margin-top: 24px; 33 + color: #555; 34 + `; 35 + 36 + export const PlaylistGrid = styled.div` 37 + display: grid; 38 + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); 39 + gap: 20px; 40 + padding-left: 20px; 41 + padding-right: 20px; 42 + margin-bottom: 40px; 43 + `; 44 + 45 + export const PlaylistCard = styled.div` 46 + position: relative; 47 + cursor: pointer; 48 + 49 + &:hover .card-actions { 50 + opacity: 1; 51 + } 52 + `; 53 + 54 + export const PlaylistCover = styled.div<{ image?: string }>` 55 + width: 100%; 56 + aspect-ratio: 1; 57 + border-radius: 6px; 58 + background-color: #e8e8e8; 59 + background-image: ${({ image }) => (image ? `url(${image})` : "none")}; 60 + background-size: cover; 61 + background-position: center; 62 + display: flex; 63 + align-items: center; 64 + justify-content: center; 65 + overflow: hidden; 66 + `; 67 + 68 + export const PlaylistName = styled.div` 69 + font-size: 14px; 70 + font-family: RockfordSansMedium; 71 + margin-top: 8px; 72 + white-space: nowrap; 73 + overflow: hidden; 74 + text-overflow: ellipsis; 75 + color: #000; 76 + `; 77 + 78 + export const PlaylistMeta = styled.div` 79 + font-size: 12px; 80 + color: #828282; 81 + margin-top: 2px; 82 + `; 83 + 84 + export const CardActions = styled.div` 85 + position: absolute; 86 + bottom: 44px; 87 + left: 8px; 88 + right: 8px; 89 + display: flex; 90 + flex-direction: row; 91 + align-items: center; 92 + justify-content: space-between; 93 + opacity: 0; 94 + transition: opacity 0.15s; 95 + `; 96 + 97 + export const CardAction = styled.button` 98 + height: 36px; 99 + width: 36px; 100 + border-radius: 18px; 101 + border: none; 102 + cursor: pointer; 103 + display: flex; 104 + align-items: center; 105 + justify-content: center; 106 + background-color: rgba(255, 255, 255, 0.85); 107 + backdrop-filter: blur(4px); 108 + 109 + &:hover { 110 + background-color: #fff; 111 + } 112 + `; 113 + 114 + export const Link = styled(RouterLink)` 115 + text-decoration: none; 116 + `;
+12 -1
webui/rockbox/src/Components/Sidebar/Sidebar.tsx
··· 1 1 import { FC } from "react"; 2 2 import { SidebarContainer, MenuItem, Header, SettingsButton } from "./styles"; 3 - import { Disc } from "@styled-icons/boxicons-regular"; 3 + import { Disc, Music } from "@styled-icons/boxicons-regular"; 4 4 import { HardDrive } from "@styled-icons/feather"; 5 5 import Artist from "../Icons/Artist"; 6 6 import Track from "../Icons/Track"; ··· 77 77 color={active === "files" ? "#fe099c" : "initial"} 78 78 /> 79 79 <div>Files</div> 80 + </MenuItem> 81 + <MenuItem 82 + color={active === "playlists" ? "#fe099c" : "initial"} 83 + to="/playlists" 84 + > 85 + <Music 86 + size={20} 87 + style={{ marginRight: 6 }} 88 + color={active === "playlists" ? "#fe099c" : "initial"} 89 + /> 90 + <div>Playlists</div> 80 91 </MenuItem> 81 92 </SidebarContainer> 82 93 );
+8
webui/rockbox/src/Containers/PlaylistDetails/SmartPlaylistDetailsPage.tsx
··· 1 + import { FC } from "react"; 2 + import PlaylistDetailsWithData from "../../Components/PlaylistDetails/PlaylistDetailsWithData"; 3 + 4 + const SmartPlaylistDetailsPage: FC = () => { 5 + return <PlaylistDetailsWithData isSmart={true} />; 6 + }; 7 + 8 + export default SmartPlaylistDetailsPage;
+8
webui/rockbox/src/Containers/PlaylistDetails/index.tsx
··· 1 + import { FC } from "react"; 2 + import PlaylistDetailsWithData from "../../Components/PlaylistDetails/PlaylistDetailsWithData"; 3 + 4 + const PlaylistDetailsPage: FC = () => { 5 + return <PlaylistDetailsWithData isSmart={false} />; 6 + }; 7 + 8 + export default PlaylistDetailsPage;
+2 -1
webui/rockbox/src/Containers/Playlists/PlaylistsPage.tsx
··· 1 1 import { FC } from "react"; 2 + import Playlists from "../../Components/Playlists"; 2 3 3 4 const PlaylistsPage: FC = () => { 4 - return <></>; 5 + return <Playlists />; 5 6 }; 6 7 7 8 export default PlaylistsPage;
+1
webui/rockbox/src/Containers/Playlists/index.tsx
··· 1 + export { default } from "./PlaylistsPage";
+50
webui/rockbox/src/GraphQL/SavedPlaylist/Mutation.ts
··· 1 + import { gql } from "@apollo/client"; 2 + 3 + export const CREATE_SAVED_PLAYLIST = gql` 4 + mutation CreateSavedPlaylist( 5 + $name: String! 6 + $description: String 7 + $trackIds: [String!] 8 + ) { 9 + createSavedPlaylist(name: $name, description: $description, trackIds: $trackIds) { 10 + id 11 + name 12 + description 13 + trackCount 14 + } 15 + } 16 + `; 17 + 18 + export const UPDATE_SAVED_PLAYLIST = gql` 19 + mutation UpdateSavedPlaylist( 20 + $id: String! 21 + $name: String! 22 + $description: String 23 + ) { 24 + updateSavedPlaylist(id: $id, name: $name, description: $description) 25 + } 26 + `; 27 + 28 + export const DELETE_SAVED_PLAYLIST = gql` 29 + mutation DeleteSavedPlaylist($id: String!) { 30 + deleteSavedPlaylist(id: $id) 31 + } 32 + `; 33 + 34 + export const ADD_TRACKS_TO_SAVED_PLAYLIST = gql` 35 + mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { 36 + addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) 37 + } 38 + `; 39 + 40 + export const REMOVE_TRACK_FROM_SAVED_PLAYLIST = gql` 41 + mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { 42 + removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) 43 + } 44 + `; 45 + 46 + export const PLAY_SAVED_PLAYLIST = gql` 47 + mutation PlaySavedPlaylist($playlistId: String!) { 48 + playSavedPlaylist(playlistId: $playlistId) 49 + } 50 + `;
+46
webui/rockbox/src/GraphQL/SavedPlaylist/Query.ts
··· 1 + import { gql } from "@apollo/client"; 2 + 3 + export const GET_SAVED_PLAYLISTS = gql` 4 + query GetSavedPlaylists { 5 + savedPlaylists { 6 + id 7 + name 8 + description 9 + image 10 + trackCount 11 + createdAt 12 + updatedAt 13 + } 14 + } 15 + `; 16 + 17 + export const GET_SAVED_PLAYLIST = gql` 18 + query GetSavedPlaylist($id: String!) { 19 + savedPlaylist(id: $id) { 20 + id 21 + name 22 + description 23 + image 24 + trackCount 25 + createdAt 26 + updatedAt 27 + } 28 + } 29 + `; 30 + 31 + export const GET_SAVED_PLAYLIST_TRACKS = gql` 32 + query GetSavedPlaylistTracks($playlistId: String!) { 33 + savedPlaylistTracks(playlistId: $playlistId) { 34 + id 35 + title 36 + artist 37 + album 38 + albumArt 39 + artistId 40 + albumId 41 + path 42 + length 43 + tracknum 44 + } 45 + } 46 + `;
+7
webui/rockbox/src/GraphQL/SmartPlaylist/Mutation.ts
··· 1 + import { gql } from "@apollo/client"; 2 + 3 + export const PLAY_SMART_PLAYLIST = gql` 4 + mutation PlaySmartPlaylist($id: String!) { 5 + playSmartPlaylist(id: $id) 6 + } 7 + `;
+46
webui/rockbox/src/GraphQL/SmartPlaylist/Query.ts
··· 1 + import { gql } from "@apollo/client"; 2 + 3 + export const GET_SMART_PLAYLISTS = gql` 4 + query GetSmartPlaylists { 5 + smartPlaylists { 6 + id 7 + name 8 + description 9 + image 10 + isSystem 11 + createdAt 12 + updatedAt 13 + } 14 + } 15 + `; 16 + 17 + export const GET_SMART_PLAYLIST = gql` 18 + query GetSmartPlaylist($id: String!) { 19 + smartPlaylist(id: $id) { 20 + id 21 + name 22 + description 23 + image 24 + isSystem 25 + createdAt 26 + updatedAt 27 + } 28 + } 29 + `; 30 + 31 + export const GET_SMART_PLAYLIST_TRACKS = gql` 32 + query GetSmartPlaylistTracks($id: String!) { 33 + smartPlaylistTracks(id: $id) { 34 + id 35 + title 36 + artist 37 + album 38 + albumArt 39 + artistId 40 + albumId 41 + path 42 + length 43 + tracknum 44 + } 45 + } 46 + `;
+294 -1
webui/rockbox/src/Hooks/GraphQL.tsx
··· 2888 2888 export type GetGlobalStatusQueryHookResult = ReturnType<typeof useGetGlobalStatusQuery>; 2889 2889 export type GetGlobalStatusLazyQueryHookResult = ReturnType<typeof useGetGlobalStatusLazyQuery>; 2890 2890 export type GetGlobalStatusSuspenseQueryHookResult = ReturnType<typeof useGetGlobalStatusSuspenseQuery>; 2891 - export type GetGlobalStatusQueryResult = Apollo.QueryResult<GetGlobalStatusQuery, GetGlobalStatusQueryVariables>; 2891 + export type GetGlobalStatusQueryResult = Apollo.QueryResult<GetGlobalStatusQuery, GetGlobalStatusQueryVariables>; 2892 + 2893 + // ── SavedPlaylist types ────────────────────────────────────────────────────── 2894 + 2895 + export type SavedPlaylist = { 2896 + __typename?: 'SavedPlaylist'; 2897 + id: string; 2898 + name: string; 2899 + description?: string | null; 2900 + image?: string | null; 2901 + trackCount: number; 2902 + createdAt: number; 2903 + updatedAt: number; 2904 + }; 2905 + 2906 + export type SmartPlaylist = { 2907 + __typename?: 'SmartPlaylist'; 2908 + id: string; 2909 + name: string; 2910 + description?: string | null; 2911 + image?: string | null; 2912 + isSystem: boolean; 2913 + createdAt: number; 2914 + updatedAt: number; 2915 + }; 2916 + 2917 + // ── Saved Playlist Queries ─────────────────────────────────────────────────── 2918 + 2919 + export type GetSavedPlaylistsQueryVariables = Exact<{ [key: string]: never; }>; 2920 + export type GetSavedPlaylistsQuery = { __typename?: 'Query', savedPlaylists: Array<{ __typename?: 'SavedPlaylist', id: string, name: string, description?: string | null, image?: string | null, trackCount: number, createdAt: number, updatedAt: number }> }; 2921 + 2922 + export const GetSavedPlaylistsDocument = gql` 2923 + query GetSavedPlaylists { 2924 + savedPlaylists { 2925 + id 2926 + name 2927 + description 2928 + image 2929 + trackCount 2930 + createdAt 2931 + updatedAt 2932 + } 2933 + } 2934 + `; 2935 + export function useGetSavedPlaylistsQuery(baseOptions?: Apollo.QueryHookOptions<GetSavedPlaylistsQuery, GetSavedPlaylistsQueryVariables>) { 2936 + const options = {...defaultOptions, ...baseOptions} 2937 + return Apollo.useQuery<GetSavedPlaylistsQuery, GetSavedPlaylistsQueryVariables>(GetSavedPlaylistsDocument, options); 2938 + } 2939 + export function useGetSavedPlaylistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSavedPlaylistsQuery, GetSavedPlaylistsQueryVariables>) { 2940 + const options = {...defaultOptions, ...baseOptions} 2941 + return Apollo.useLazyQuery<GetSavedPlaylistsQuery, GetSavedPlaylistsQueryVariables>(GetSavedPlaylistsDocument, options); 2942 + } 2943 + export type GetSavedPlaylistsQueryResult = Apollo.QueryResult<GetSavedPlaylistsQuery, GetSavedPlaylistsQueryVariables>; 2944 + 2945 + export type GetSavedPlaylistQueryVariables = Exact<{ id: string; }>; 2946 + export type GetSavedPlaylistQuery = { __typename?: 'Query', savedPlaylist?: { __typename?: 'SavedPlaylist', id: string, name: string, description?: string | null, image?: string | null, trackCount: number, createdAt: number, updatedAt: number } | null }; 2947 + 2948 + export const GetSavedPlaylistDocument = gql` 2949 + query GetSavedPlaylist($id: String!) { 2950 + savedPlaylist(id: $id) { 2951 + id 2952 + name 2953 + description 2954 + image 2955 + trackCount 2956 + createdAt 2957 + updatedAt 2958 + } 2959 + } 2960 + `; 2961 + export function useGetSavedPlaylistQuery(baseOptions: Apollo.QueryHookOptions<GetSavedPlaylistQuery, GetSavedPlaylistQueryVariables> & ({ variables: GetSavedPlaylistQueryVariables; skip?: boolean; } | { skip: boolean; })) { 2962 + const options = {...defaultOptions, ...baseOptions} 2963 + return Apollo.useQuery<GetSavedPlaylistQuery, GetSavedPlaylistQueryVariables>(GetSavedPlaylistDocument, options); 2964 + } 2965 + export function useGetSavedPlaylistLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSavedPlaylistQuery, GetSavedPlaylistQueryVariables>) { 2966 + const options = {...defaultOptions, ...baseOptions} 2967 + return Apollo.useLazyQuery<GetSavedPlaylistQuery, GetSavedPlaylistQueryVariables>(GetSavedPlaylistDocument, options); 2968 + } 2969 + export type GetSavedPlaylistQueryResult = Apollo.QueryResult<GetSavedPlaylistQuery, GetSavedPlaylistQueryVariables>; 2970 + 2971 + export type GetSavedPlaylistTracksQueryVariables = Exact<{ playlistId: string; }>; 2972 + export type GetSavedPlaylistTracksQuery = { __typename?: 'Query', savedPlaylistTracks: Array<{ __typename?: 'Track', id?: string | null, title: string, artist: string, album: string, albumArt?: string | null, artistId?: string | null, albumId?: string | null, path: string, length: number, tracknum: number }> }; 2973 + 2974 + export const GetSavedPlaylistTracksDocument = gql` 2975 + query GetSavedPlaylistTracks($playlistId: String!) { 2976 + savedPlaylistTracks(playlistId: $playlistId) { 2977 + id 2978 + title 2979 + artist 2980 + album 2981 + albumArt 2982 + artistId 2983 + albumId 2984 + path 2985 + length 2986 + tracknum 2987 + } 2988 + } 2989 + `; 2990 + export function useGetSavedPlaylistTracksQuery(baseOptions: Apollo.QueryHookOptions<GetSavedPlaylistTracksQuery, GetSavedPlaylistTracksQueryVariables> & ({ variables: GetSavedPlaylistTracksQueryVariables; skip?: boolean; } | { skip: boolean; })) { 2991 + const options = {...defaultOptions, ...baseOptions} 2992 + return Apollo.useQuery<GetSavedPlaylistTracksQuery, GetSavedPlaylistTracksQueryVariables>(GetSavedPlaylistTracksDocument, options); 2993 + } 2994 + export function useGetSavedPlaylistTracksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSavedPlaylistTracksQuery, GetSavedPlaylistTracksQueryVariables>) { 2995 + const options = {...defaultOptions, ...baseOptions} 2996 + return Apollo.useLazyQuery<GetSavedPlaylistTracksQuery, GetSavedPlaylistTracksQueryVariables>(GetSavedPlaylistTracksDocument, options); 2997 + } 2998 + export type GetSavedPlaylistTracksQueryResult = Apollo.QueryResult<GetSavedPlaylistTracksQuery, GetSavedPlaylistTracksQueryVariables>; 2999 + 3000 + // ── Smart Playlist Queries ─────────────────────────────────────────────────── 3001 + 3002 + export type GetSmartPlaylistsQueryVariables = Exact<{ [key: string]: never; }>; 3003 + export type GetSmartPlaylistsQuery = { __typename?: 'Query', smartPlaylists: Array<{ __typename?: 'SmartPlaylist', id: string, name: string, description?: string | null, image?: string | null, isSystem: boolean, createdAt: number, updatedAt: number }> }; 3004 + 3005 + export const GetSmartPlaylistsDocument = gql` 3006 + query GetSmartPlaylists { 3007 + smartPlaylists { 3008 + id 3009 + name 3010 + description 3011 + image 3012 + isSystem 3013 + createdAt 3014 + updatedAt 3015 + } 3016 + } 3017 + `; 3018 + export function useGetSmartPlaylistsQuery(baseOptions?: Apollo.QueryHookOptions<GetSmartPlaylistsQuery, GetSmartPlaylistsQueryVariables>) { 3019 + const options = {...defaultOptions, ...baseOptions} 3020 + return Apollo.useQuery<GetSmartPlaylistsQuery, GetSmartPlaylistsQueryVariables>(GetSmartPlaylistsDocument, options); 3021 + } 3022 + export function useGetSmartPlaylistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSmartPlaylistsQuery, GetSmartPlaylistsQueryVariables>) { 3023 + const options = {...defaultOptions, ...baseOptions} 3024 + return Apollo.useLazyQuery<GetSmartPlaylistsQuery, GetSmartPlaylistsQueryVariables>(GetSmartPlaylistsDocument, options); 3025 + } 3026 + export type GetSmartPlaylistsQueryResult = Apollo.QueryResult<GetSmartPlaylistsQuery, GetSmartPlaylistsQueryVariables>; 3027 + 3028 + export type GetSmartPlaylistQueryVariables = Exact<{ id: string; }>; 3029 + export type GetSmartPlaylistQuery = { __typename?: 'Query', smartPlaylist?: { __typename?: 'SmartPlaylist', id: string, name: string, description?: string | null, image?: string | null, isSystem: boolean, createdAt: number, updatedAt: number } | null }; 3030 + 3031 + export const GetSmartPlaylistDocument = gql` 3032 + query GetSmartPlaylist($id: String!) { 3033 + smartPlaylist(id: $id) { 3034 + id 3035 + name 3036 + description 3037 + image 3038 + isSystem 3039 + createdAt 3040 + updatedAt 3041 + } 3042 + } 3043 + `; 3044 + export function useGetSmartPlaylistQuery(baseOptions: Apollo.QueryHookOptions<GetSmartPlaylistQuery, GetSmartPlaylistQueryVariables> & ({ variables: GetSmartPlaylistQueryVariables; skip?: boolean; } | { skip: boolean; })) { 3045 + const options = {...defaultOptions, ...baseOptions} 3046 + return Apollo.useQuery<GetSmartPlaylistQuery, GetSmartPlaylistQueryVariables>(GetSmartPlaylistDocument, options); 3047 + } 3048 + export type GetSmartPlaylistQueryResult = Apollo.QueryResult<GetSmartPlaylistQuery, GetSmartPlaylistQueryVariables>; 3049 + 3050 + export type GetSmartPlaylistTracksQueryVariables = Exact<{ id: string; }>; 3051 + export type GetSmartPlaylistTracksQuery = { __typename?: 'Query', smartPlaylistTracks: Array<{ __typename?: 'Track', id?: string | null, title: string, artist: string, album: string, albumArt?: string | null, artistId?: string | null, albumId?: string | null, path: string, length: number, tracknum: number }> }; 3052 + 3053 + export const GetSmartPlaylistTracksDocument = gql` 3054 + query GetSmartPlaylistTracks($id: String!) { 3055 + smartPlaylistTracks(id: $id) { 3056 + id 3057 + title 3058 + artist 3059 + album 3060 + albumArt 3061 + artistId 3062 + albumId 3063 + path 3064 + length 3065 + tracknum 3066 + } 3067 + } 3068 + `; 3069 + export function useGetSmartPlaylistTracksQuery(baseOptions: Apollo.QueryHookOptions<GetSmartPlaylistTracksQuery, GetSmartPlaylistTracksQueryVariables> & ({ variables: GetSmartPlaylistTracksQueryVariables; skip?: boolean; } | { skip: boolean; })) { 3070 + const options = {...defaultOptions, ...baseOptions} 3071 + return Apollo.useQuery<GetSmartPlaylistTracksQuery, GetSmartPlaylistTracksQueryVariables>(GetSmartPlaylistTracksDocument, options); 3072 + } 3073 + export function useGetSmartPlaylistTracksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSmartPlaylistTracksQuery, GetSmartPlaylistTracksQueryVariables>) { 3074 + const options = {...defaultOptions, ...baseOptions} 3075 + return Apollo.useLazyQuery<GetSmartPlaylistTracksQuery, GetSmartPlaylistTracksQueryVariables>(GetSmartPlaylistTracksDocument, options); 3076 + } 3077 + export type GetSmartPlaylistTracksQueryResult = Apollo.QueryResult<GetSmartPlaylistTracksQuery, GetSmartPlaylistTracksQueryVariables>; 3078 + 3079 + // ── Saved Playlist Mutations ───────────────────────────────────────────────── 3080 + 3081 + export type CreateSavedPlaylistMutationVariables = Exact<{ name: string; description?: InputMaybe<string>; trackIds?: InputMaybe<Array<string>>; }>; 3082 + export type CreateSavedPlaylistMutation = { __typename?: 'Mutation', createSavedPlaylist: { __typename?: 'SavedPlaylist', id: string, name: string, description?: string | null, trackCount: number } }; 3083 + 3084 + export const CreateSavedPlaylistDocument = gql` 3085 + mutation CreateSavedPlaylist($name: String!, $description: String, $trackIds: [String!]) { 3086 + createSavedPlaylist(name: $name, description: $description, trackIds: $trackIds) { 3087 + id 3088 + name 3089 + description 3090 + trackCount 3091 + } 3092 + } 3093 + `; 3094 + export function useCreateSavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<CreateSavedPlaylistMutation, CreateSavedPlaylistMutationVariables>) { 3095 + const options = {...defaultOptions, ...baseOptions} 3096 + return Apollo.useMutation<CreateSavedPlaylistMutation, CreateSavedPlaylistMutationVariables>(CreateSavedPlaylistDocument, options); 3097 + } 3098 + export type CreateSavedPlaylistMutationResult = Apollo.MutationResult<CreateSavedPlaylistMutation>; 3099 + 3100 + export type UpdateSavedPlaylistMutationVariables = Exact<{ id: string; name: string; description?: InputMaybe<string>; }>; 3101 + export type UpdateSavedPlaylistMutation = { __typename?: 'Mutation', updateSavedPlaylist: boolean }; 3102 + 3103 + export const UpdateSavedPlaylistDocument = gql` 3104 + mutation UpdateSavedPlaylist($id: String!, $name: String!, $description: String) { 3105 + updateSavedPlaylist(id: $id, name: $name, description: $description) 3106 + } 3107 + `; 3108 + export function useUpdateSavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<UpdateSavedPlaylistMutation, UpdateSavedPlaylistMutationVariables>) { 3109 + const options = {...defaultOptions, ...baseOptions} 3110 + return Apollo.useMutation<UpdateSavedPlaylistMutation, UpdateSavedPlaylistMutationVariables>(UpdateSavedPlaylistDocument, options); 3111 + } 3112 + export type UpdateSavedPlaylistMutationResult = Apollo.MutationResult<UpdateSavedPlaylistMutation>; 3113 + 3114 + export type DeleteSavedPlaylistMutationVariables = Exact<{ id: string; }>; 3115 + export type DeleteSavedPlaylistMutation = { __typename?: 'Mutation', deleteSavedPlaylist: boolean }; 3116 + 3117 + export const DeleteSavedPlaylistDocument = gql` 3118 + mutation DeleteSavedPlaylist($id: String!) { 3119 + deleteSavedPlaylist(id: $id) 3120 + } 3121 + `; 3122 + export function useDeleteSavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<DeleteSavedPlaylistMutation, DeleteSavedPlaylistMutationVariables>) { 3123 + const options = {...defaultOptions, ...baseOptions} 3124 + return Apollo.useMutation<DeleteSavedPlaylistMutation, DeleteSavedPlaylistMutationVariables>(DeleteSavedPlaylistDocument, options); 3125 + } 3126 + export type DeleteSavedPlaylistMutationResult = Apollo.MutationResult<DeleteSavedPlaylistMutation>; 3127 + 3128 + export type AddTracksToSavedPlaylistMutationVariables = Exact<{ playlistId: string; trackIds: Array<string>; }>; 3129 + export type AddTracksToSavedPlaylistMutation = { __typename?: 'Mutation', addTracksToSavedPlaylist: boolean }; 3130 + 3131 + export const AddTracksToSavedPlaylistDocument = gql` 3132 + mutation AddTracksToSavedPlaylist($playlistId: String!, $trackIds: [String!]!) { 3133 + addTracksToSavedPlaylist(playlistId: $playlistId, trackIds: $trackIds) 3134 + } 3135 + `; 3136 + export function useAddTracksToSavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<AddTracksToSavedPlaylistMutation, AddTracksToSavedPlaylistMutationVariables>) { 3137 + const options = {...defaultOptions, ...baseOptions} 3138 + return Apollo.useMutation<AddTracksToSavedPlaylistMutation, AddTracksToSavedPlaylistMutationVariables>(AddTracksToSavedPlaylistDocument, options); 3139 + } 3140 + export type AddTracksToSavedPlaylistMutationResult = Apollo.MutationResult<AddTracksToSavedPlaylistMutation>; 3141 + 3142 + export type RemoveTrackFromSavedPlaylistMutationVariables = Exact<{ playlistId: string; trackId: string; }>; 3143 + export type RemoveTrackFromSavedPlaylistMutation = { __typename?: 'Mutation', removeTrackFromSavedPlaylist: boolean }; 3144 + 3145 + export const RemoveTrackFromSavedPlaylistDocument = gql` 3146 + mutation RemoveTrackFromSavedPlaylist($playlistId: String!, $trackId: String!) { 3147 + removeTrackFromSavedPlaylist(playlistId: $playlistId, trackId: $trackId) 3148 + } 3149 + `; 3150 + export function useRemoveTrackFromSavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<RemoveTrackFromSavedPlaylistMutation, RemoveTrackFromSavedPlaylistMutationVariables>) { 3151 + const options = {...defaultOptions, ...baseOptions} 3152 + return Apollo.useMutation<RemoveTrackFromSavedPlaylistMutation, RemoveTrackFromSavedPlaylistMutationVariables>(RemoveTrackFromSavedPlaylistDocument, options); 3153 + } 3154 + export type RemoveTrackFromSavedPlaylistMutationResult = Apollo.MutationResult<RemoveTrackFromSavedPlaylistMutation>; 3155 + 3156 + export type PlaySavedPlaylistMutationVariables = Exact<{ playlistId: string; }>; 3157 + export type PlaySavedPlaylistMutation = { __typename?: 'Mutation', playSavedPlaylist: boolean }; 3158 + 3159 + export const PlaySavedPlaylistDocument = gql` 3160 + mutation PlaySavedPlaylist($playlistId: String!) { 3161 + playSavedPlaylist(playlistId: $playlistId) 3162 + } 3163 + `; 3164 + export function usePlaySavedPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<PlaySavedPlaylistMutation, PlaySavedPlaylistMutationVariables>) { 3165 + const options = {...defaultOptions, ...baseOptions} 3166 + return Apollo.useMutation<PlaySavedPlaylistMutation, PlaySavedPlaylistMutationVariables>(PlaySavedPlaylistDocument, options); 3167 + } 3168 + export type PlaySavedPlaylistMutationResult = Apollo.MutationResult<PlaySavedPlaylistMutation>; 3169 + 3170 + // ── Smart Playlist Mutations ───────────────────────────────────────────────── 3171 + 3172 + export type PlaySmartPlaylistMutationVariables = Exact<{ id: string; }>; 3173 + export type PlaySmartPlaylistMutation = { __typename?: 'Mutation', playSmartPlaylist: boolean }; 3174 + 3175 + export const PlaySmartPlaylistDocument = gql` 3176 + mutation PlaySmartPlaylist($id: String!) { 3177 + playSmartPlaylist(id: $id) 3178 + } 3179 + `; 3180 + export function usePlaySmartPlaylistMutation(baseOptions?: Apollo.MutationHookOptions<PlaySmartPlaylistMutation, PlaySmartPlaylistMutationVariables>) { 3181 + const options = {...defaultOptions, ...baseOptions} 3182 + return Apollo.useMutation<PlaySmartPlaylistMutation, PlaySmartPlaylistMutationVariables>(PlaySmartPlaylistDocument, options); 3183 + } 3184 + export type PlaySmartPlaylistMutationResult = Apollo.MutationResult<PlaySmartPlaylistMutation>;
+1 -1
webui/rockbox/tsconfig.app.tsbuildinfo
··· 1 - {"root":["./src/app.tsx","./src/theme.ts","./src/constants.ts","./src/emotion.d.ts","./src/main.tsx","./src/mocks.ts","./src/vite-env.d.ts","./src/components/album/album.stories.tsx","./src/components/album/album.test.tsx","./src/components/album/album.tsx","./src/components/album/albumwithdata.tsx","./src/components/album/index.tsx","./src/components/album/styles.tsx","./src/components/album/contextmenu/childmenu.tsx","./src/components/album/contextmenu/contextmenu.stories.tsx","./src/components/album/contextmenu/contextmenu.test.tsx","./src/components/album/contextmenu/contextmenu.tsx","./src/components/album/contextmenu/contextmenuwithdata.tsx","./src/components/album/contextmenu/index.tsx","./src/components/album/contextmenu/styles.tsx","./src/components/albumdetails/albumdetails.stories.tsx","./src/components/albumdetails/albumdetails.test.tsx","./src/components/albumdetails/albumdetails.tsx","./src/components/albumdetails/albumdetailswithdata.tsx","./src/components/albumdetails/index.tsx","./src/components/albumdetails/mocks.tsx","./src/components/albumdetails/styles.tsx","./src/components/albums/albums.stories.tsx","./src/components/albums/albums.test.tsx","./src/components/albums/albums.tsx","./src/components/albums/albumswithdata.tsx","./src/components/albums/index.tsx","./src/components/albums/mocks.tsx","./src/components/albums/styles.tsx","./src/components/artistdetails/artistdetails.stories.tsx","./src/components/artistdetails/artistdetails.test.tsx","./src/components/artistdetails/artistdetails.tsx","./src/components/artistdetails/artistdetailswithdata.tsx","./src/components/artistdetails/index.tsx","./src/components/artistdetails/mocks.tsx","./src/components/artistdetails/styles.tsx","./src/components/artists/artists.stories.tsx","./src/components/artists/artists.test.tsx","./src/components/artists/artists.tsx","./src/components/artists/artistswithdata.tsx","./src/components/artists/index.tsx","./src/components/artists/mocks.tsx","./src/components/artists/styles.tsx","./src/components/button/button.test.tsx","./src/components/button/button.tsx","./src/components/button/index.tsx","./src/components/contextmenu/childmenu.tsx","./src/components/contextmenu/contextmenu.stories.tsx","./src/components/contextmenu/contextmenu.test.tsx","./src/components/contextmenu/contextmenu.tsx","./src/components/contextmenu/contextmenuwithdata.tsx","./src/components/contextmenu/index.tsx","./src/components/contextmenu/styles.tsx","./src/components/controlbar/controlbar.stories.tsx","./src/components/controlbar/controlbar.test.tsx","./src/components/controlbar/controlbar.tsx","./src/components/controlbar/controlbarstate.tsx","./src/components/controlbar/controlbarwithdata.tsx","./src/components/controlbar/index.tsx","./src/components/controlbar/styles.tsx","./src/components/controlbar/currenttrack/currenttrack.tsx","./src/components/controlbar/currenttrack/index.tsx","./src/components/controlbar/currenttrack/styles.ts","./src/components/controlbar/devicelist/devicelist.tsx","./src/components/controlbar/devicelist/devicelistwithdata.tsx","./src/components/controlbar/devicelist/devicestate.tsx","./src/components/controlbar/devicelist/index.tsx","./src/components/controlbar/devicelist/styles.ts","./src/components/controlbar/playqueue/playqueue.stories.tsx","./src/components/controlbar/playqueue/playqueue.test.tsx","./src/components/controlbar/playqueue/playqueue.tsx","./src/components/controlbar/playqueue/playqueuewithdata.tsx","./src/components/controlbar/playqueue/index.tsx","./src/components/controlbar/playqueue/mocks.tsx","./src/components/controlbar/playqueue/styles.tsx","./src/components/controlbar/rightmenu/rightmenu.tsx","./src/components/controlbar/rightmenu/index.tsx","./src/components/controlbar/rightmenu/styles.tsx","./src/components/controlbar/rightmenu/volume/volume.stories.tsx","./src/components/controlbar/rightmenu/volume/volume.test.tsx","./src/components/controlbar/rightmenu/volume/volume.tsx","./src/components/controlbar/rightmenu/volume/volumewithdata.tsx","./src/components/controlbar/rightmenu/volume/index.tsx","./src/components/controlbar/rightmenu/volume/styles.tsx","./src/components/extensions/extensions.stories.tsx","./src/components/extensions/extensions.tsx","./src/components/extensions/index.tsx","./src/components/files/files.stories.tsx","./src/components/files/files.test.tsx","./src/components/files/files.tsx","./src/components/files/fileswithdata.tsx","./src/components/files/index.tsx","./src/components/files/mocks.tsx","./src/components/files/styles.tsx","./src/components/files/contextmenu/childmenu.tsx","./src/components/files/contextmenu/contextmenu.stories.tsx","./src/components/files/contextmenu/contextmenu.test.tsx","./src/components/files/contextmenu/contextmenu.tsx","./src/components/files/contextmenu/contextmenuwithdata.tsx","./src/components/files/contextmenu/index.tsx","./src/components/files/contextmenu/styles.tsx","./src/components/filter/filter.test.tsx","./src/components/filter/filter.tsx","./src/components/filter/filterstate.tsx","./src/components/filter/filterwithdata.tsx","./src/components/filter/index.tsx","./src/components/folder/folder.stories.tsx","./src/components/folder/folder.tsx","./src/components/folder/index.tsx","./src/components/icons/add.tsx","./src/components/icons/albumcover.tsx","./src/components/icons/arrowback.tsx","./src/components/icons/artist.tsx","./src/components/icons/heart.tsx","./src/components/icons/heartoutline.tsx","./src/components/icons/next.tsx","./src/components/icons/pause.tsx","./src/components/icons/play.tsx","./src/components/icons/previous.tsx","./src/components/icons/repeat.tsx","./src/components/icons/search.tsx","./src/components/icons/shuffle.tsx","./src/components/icons/speaker.tsx","./src/components/icons/track.tsx","./src/components/likes/likes.tsx","./src/components/likes/likesstate.ts","./src/components/likes/likeswithdata.tsx","./src/components/likes/index.tsx","./src/components/likes/styles.tsx","./src/components/mainview/mainview.tsx","./src/components/mainview/mainviewwithdata.tsx","./src/components/mainview/index.tsx","./src/components/mainview/styles.tsx","./src/components/playlistdetails/playlistdetails.stories.tsx","./src/components/playlistdetails/playlistdetails.tsx","./src/components/playlistdetails/index.tsx","./src/components/playlists/playlists.stories.tsx","./src/components/playlists/playlists.tsx","./src/components/playlists/index.tsx","./src/components/settings/settings.tsx","./src/components/settings/settingsstate.ts","./src/components/settings/settingswithdata.tsx","./src/components/settings/index.tsx","./src/components/settings/styles.tsx","./src/components/settings/library/library.tsx","./src/components/settings/library/librarywithdata.tsx","./src/components/settings/library/index.tsx","./src/components/settings/library/styles.tsx","./src/components/settings/playback/playback.tsx","./src/components/settings/playback/playbackwithdata.tsx","./src/components/settings/playback/consts.ts","./src/components/settings/playback/index.tsx","./src/components/settings/playback/styles.tsx","./src/components/settings/sound/sound.tsx","./src/components/settings/sound/soundwithdata.tsx","./src/components/settings/sound/index.tsx","./src/components/settings/sound/styles.tsx","./src/components/settings/sound/equalizer/equalizer.tsx","./src/components/settings/sound/equalizer/equalizerwithdata.tsx","./src/components/settings/sound/equalizer/index.tsx","./src/components/settings/sound/equalizer/styles.tsx","./src/components/sidebar/sidebar.test.tsx","./src/components/sidebar/sidebar.tsx","./src/components/sidebar/sidebarwithdata.tsx","./src/components/sidebar/stidebar.stories.tsx","./src/components/sidebar/index.tsx","./src/components/sidebar/styles.tsx","./src/components/switch/switch.tsx","./src/components/switch/index.tsx","./src/components/table/table.tsx","./src/components/table/index.tsx","./src/components/tracks/tracks.stories.tsx","./src/components/tracks/tracks.test.tsx","./src/components/tracks/tracks.tsx","./src/components/tracks/trackswithdata.tsx","./src/components/tracks/index.tsx","./src/components/tracks/mocks.tsx","./src/components/tracks/styles.tsx","./src/components/virtualizedtable/virtualizedtable.tsx","./src/components/virtualizedtable/index.tsx","./src/containers/albumdetails/albumdetailspage.tsx","./src/containers/albumdetails/index.tsx","./src/containers/albums/albumspage.tsx","./src/containers/albums/index.tsx","./src/containers/artistdetails/artistdetailspage.tsx","./src/containers/artistdetails/index.tsx","./src/containers/artists/artistspage.tsx","./src/containers/artists/index.tsx","./src/containers/extensions/extensionspage.tsx","./src/containers/extensions/index.tsx","./src/containers/files/filespage.tsx","./src/containers/files/index.tsx","./src/containers/likes/likespage.tsx","./src/containers/likes/index.tsx","./src/containers/playlists/playlistspage.tsx","./src/containers/playlists/index.tsx","./src/containers/settings/settingspage.tsx","./src/containers/settings/index.tsx","./src/containers/tracks/trackspage.tsx","./src/containers/tracks/index.tsx","./src/graphql/browse/query.ts","./src/graphql/device/mutation.ts","./src/graphql/device/query.ts","./src/graphql/library/mutation.ts","./src/graphql/library/query.ts","./src/graphql/playback/mutation.ts","./src/graphql/playback/query.ts","./src/graphql/playback/subscription.ts","./src/graphql/playlist/mutation.ts","./src/graphql/playlist/query.ts","./src/graphql/playlist/subscription.ts","./src/graphql/settings/mutation.ts","./src/graphql/settings/query.ts","./src/graphql/sound/mutation.tsx","./src/graphql/system/query.ts","./src/hooks/graphql.tsx","./src/hooks/useformat.tsx","./src/hooks/useplayqueue.tsx","./src/hooks/useresumeplaylist.tsx","./src/hooks/usesettings.tsx","./src/providers/graphqlprovider.tsx","./src/providers/themeprovider.tsx","./src/providers/index.tsx","./src/types/file.ts","./src/types/playlist.ts","./src/types/track.ts","./src/stories/button.stories.ts","./src/stories/button.tsx","./src/stories/header.stories.ts","./src/stories/header.tsx","./src/stories/page.stories.ts","./src/stories/page.tsx"],"version":"5.6.2"} 1 + {"root":["./src/app.tsx","./src/theme.ts","./src/constants.ts","./src/emotion.d.ts","./src/main.tsx","./src/mocks.ts","./src/vite-env.d.ts","./src/components/album/album.stories.tsx","./src/components/album/album.test.tsx","./src/components/album/album.tsx","./src/components/album/albumwithdata.tsx","./src/components/album/index.tsx","./src/components/album/styles.tsx","./src/components/album/contextmenu/childmenu.tsx","./src/components/album/contextmenu/contextmenu.stories.tsx","./src/components/album/contextmenu/contextmenu.test.tsx","./src/components/album/contextmenu/contextmenu.tsx","./src/components/album/contextmenu/contextmenuwithdata.tsx","./src/components/album/contextmenu/index.tsx","./src/components/album/contextmenu/styles.tsx","./src/components/albumdetails/albumdetails.stories.tsx","./src/components/albumdetails/albumdetails.test.tsx","./src/components/albumdetails/albumdetails.tsx","./src/components/albumdetails/albumdetailswithdata.tsx","./src/components/albumdetails/index.tsx","./src/components/albumdetails/mocks.tsx","./src/components/albumdetails/styles.tsx","./src/components/albums/albums.stories.tsx","./src/components/albums/albums.test.tsx","./src/components/albums/albums.tsx","./src/components/albums/albumswithdata.tsx","./src/components/albums/index.tsx","./src/components/albums/mocks.tsx","./src/components/albums/styles.tsx","./src/components/artistdetails/artistdetails.stories.tsx","./src/components/artistdetails/artistdetails.test.tsx","./src/components/artistdetails/artistdetails.tsx","./src/components/artistdetails/artistdetailswithdata.tsx","./src/components/artistdetails/index.tsx","./src/components/artistdetails/mocks.tsx","./src/components/artistdetails/styles.tsx","./src/components/artists/artists.stories.tsx","./src/components/artists/artists.test.tsx","./src/components/artists/artists.tsx","./src/components/artists/artistswithdata.tsx","./src/components/artists/index.tsx","./src/components/artists/mocks.tsx","./src/components/artists/styles.tsx","./src/components/button/button.test.tsx","./src/components/button/button.tsx","./src/components/button/index.tsx","./src/components/contextmenu/childmenu.tsx","./src/components/contextmenu/contextmenu.stories.tsx","./src/components/contextmenu/contextmenu.test.tsx","./src/components/contextmenu/contextmenu.tsx","./src/components/contextmenu/contextmenuwithdata.tsx","./src/components/contextmenu/index.tsx","./src/components/contextmenu/styles.tsx","./src/components/controlbar/controlbar.stories.tsx","./src/components/controlbar/controlbar.test.tsx","./src/components/controlbar/controlbar.tsx","./src/components/controlbar/controlbarstate.tsx","./src/components/controlbar/controlbarwithdata.tsx","./src/components/controlbar/index.tsx","./src/components/controlbar/styles.tsx","./src/components/controlbar/currenttrack/currenttrack.tsx","./src/components/controlbar/currenttrack/index.tsx","./src/components/controlbar/currenttrack/styles.ts","./src/components/controlbar/devicelist/devicelist.tsx","./src/components/controlbar/devicelist/devicelistwithdata.tsx","./src/components/controlbar/devicelist/devicestate.tsx","./src/components/controlbar/devicelist/index.tsx","./src/components/controlbar/devicelist/styles.ts","./src/components/controlbar/playqueue/playqueue.stories.tsx","./src/components/controlbar/playqueue/playqueue.test.tsx","./src/components/controlbar/playqueue/playqueue.tsx","./src/components/controlbar/playqueue/playqueuewithdata.tsx","./src/components/controlbar/playqueue/index.tsx","./src/components/controlbar/playqueue/mocks.tsx","./src/components/controlbar/playqueue/styles.tsx","./src/components/controlbar/rightmenu/rightmenu.tsx","./src/components/controlbar/rightmenu/index.tsx","./src/components/controlbar/rightmenu/styles.tsx","./src/components/controlbar/rightmenu/volume/volume.stories.tsx","./src/components/controlbar/rightmenu/volume/volume.test.tsx","./src/components/controlbar/rightmenu/volume/volume.tsx","./src/components/controlbar/rightmenu/volume/volumewithdata.tsx","./src/components/controlbar/rightmenu/volume/index.tsx","./src/components/controlbar/rightmenu/volume/styles.tsx","./src/components/extensions/extensions.stories.tsx","./src/components/extensions/extensions.tsx","./src/components/extensions/index.tsx","./src/components/files/files.stories.tsx","./src/components/files/files.test.tsx","./src/components/files/files.tsx","./src/components/files/fileswithdata.tsx","./src/components/files/index.tsx","./src/components/files/mocks.tsx","./src/components/files/styles.tsx","./src/components/files/contextmenu/childmenu.tsx","./src/components/files/contextmenu/contextmenu.stories.tsx","./src/components/files/contextmenu/contextmenu.test.tsx","./src/components/files/contextmenu/contextmenu.tsx","./src/components/files/contextmenu/contextmenuwithdata.tsx","./src/components/files/contextmenu/index.tsx","./src/components/files/contextmenu/styles.tsx","./src/components/filter/filter.test.tsx","./src/components/filter/filter.tsx","./src/components/filter/filterstate.tsx","./src/components/filter/filterwithdata.tsx","./src/components/filter/index.tsx","./src/components/folder/folder.stories.tsx","./src/components/folder/folder.tsx","./src/components/folder/index.tsx","./src/components/icons/add.tsx","./src/components/icons/albumcover.tsx","./src/components/icons/arrowback.tsx","./src/components/icons/artist.tsx","./src/components/icons/heart.tsx","./src/components/icons/heartoutline.tsx","./src/components/icons/next.tsx","./src/components/icons/pause.tsx","./src/components/icons/play.tsx","./src/components/icons/previous.tsx","./src/components/icons/repeat.tsx","./src/components/icons/search.tsx","./src/components/icons/shuffle.tsx","./src/components/icons/speaker.tsx","./src/components/icons/track.tsx","./src/components/likes/likes.tsx","./src/components/likes/likesstate.ts","./src/components/likes/likeswithdata.tsx","./src/components/likes/index.tsx","./src/components/likes/styles.tsx","./src/components/mainview/mainview.tsx","./src/components/mainview/mainviewwithdata.tsx","./src/components/mainview/index.tsx","./src/components/mainview/styles.tsx","./src/components/playlistdetails/playlistdetails.stories.tsx","./src/components/playlistdetails/playlistdetails.tsx","./src/components/playlistdetails/playlistdetailswithdata.tsx","./src/components/playlistdetails/index.tsx","./src/components/playlistdetails/styles.tsx","./src/components/playlists/playlistmodal.tsx","./src/components/playlists/playlists.stories.tsx","./src/components/playlists/playlists.tsx","./src/components/playlists/playlistswithdata.tsx","./src/components/playlists/index.tsx","./src/components/playlists/styles.tsx","./src/components/settings/settings.tsx","./src/components/settings/settingsstate.ts","./src/components/settings/settingswithdata.tsx","./src/components/settings/index.tsx","./src/components/settings/styles.tsx","./src/components/settings/library/library.tsx","./src/components/settings/library/librarywithdata.tsx","./src/components/settings/library/index.tsx","./src/components/settings/library/styles.tsx","./src/components/settings/playback/playback.tsx","./src/components/settings/playback/playbackwithdata.tsx","./src/components/settings/playback/consts.ts","./src/components/settings/playback/index.tsx","./src/components/settings/playback/styles.tsx","./src/components/settings/sound/sound.tsx","./src/components/settings/sound/soundwithdata.tsx","./src/components/settings/sound/index.tsx","./src/components/settings/sound/styles.tsx","./src/components/settings/sound/equalizer/equalizer.tsx","./src/components/settings/sound/equalizer/equalizerwithdata.tsx","./src/components/settings/sound/equalizer/index.tsx","./src/components/settings/sound/equalizer/styles.tsx","./src/components/sidebar/sidebar.test.tsx","./src/components/sidebar/sidebar.tsx","./src/components/sidebar/sidebarwithdata.tsx","./src/components/sidebar/stidebar.stories.tsx","./src/components/sidebar/index.tsx","./src/components/sidebar/styles.tsx","./src/components/switch/switch.tsx","./src/components/switch/index.tsx","./src/components/table/table.tsx","./src/components/table/index.tsx","./src/components/tracks/tracks.stories.tsx","./src/components/tracks/tracks.test.tsx","./src/components/tracks/tracks.tsx","./src/components/tracks/trackswithdata.tsx","./src/components/tracks/index.tsx","./src/components/tracks/mocks.tsx","./src/components/tracks/styles.tsx","./src/components/virtualizedtable/virtualizedtable.tsx","./src/components/virtualizedtable/index.tsx","./src/containers/albumdetails/albumdetailspage.tsx","./src/containers/albumdetails/index.tsx","./src/containers/albums/albumspage.tsx","./src/containers/albums/index.tsx","./src/containers/artistdetails/artistdetailspage.tsx","./src/containers/artistdetails/index.tsx","./src/containers/artists/artistspage.tsx","./src/containers/artists/index.tsx","./src/containers/extensions/extensionspage.tsx","./src/containers/extensions/index.tsx","./src/containers/files/filespage.tsx","./src/containers/files/index.tsx","./src/containers/likes/likespage.tsx","./src/containers/likes/index.tsx","./src/containers/playlistdetails/smartplaylistdetailspage.tsx","./src/containers/playlistdetails/index.tsx","./src/containers/playlists/playlistspage.tsx","./src/containers/playlists/index.tsx","./src/containers/settings/settingspage.tsx","./src/containers/settings/index.tsx","./src/containers/tracks/trackspage.tsx","./src/containers/tracks/index.tsx","./src/graphql/browse/query.ts","./src/graphql/device/mutation.ts","./src/graphql/device/query.ts","./src/graphql/library/mutation.ts","./src/graphql/library/query.ts","./src/graphql/playback/mutation.ts","./src/graphql/playback/query.ts","./src/graphql/playback/subscription.ts","./src/graphql/playlist/mutation.ts","./src/graphql/playlist/query.ts","./src/graphql/playlist/subscription.ts","./src/graphql/savedplaylist/mutation.ts","./src/graphql/savedplaylist/query.ts","./src/graphql/settings/mutation.ts","./src/graphql/settings/query.ts","./src/graphql/smartplaylist/mutation.ts","./src/graphql/smartplaylist/query.ts","./src/graphql/sound/mutation.tsx","./src/graphql/system/query.ts","./src/hooks/graphql.tsx","./src/hooks/useformat.tsx","./src/hooks/useplayqueue.tsx","./src/hooks/useresumeplaylist.tsx","./src/hooks/usesettings.tsx","./src/providers/graphqlprovider.tsx","./src/providers/themeprovider.tsx","./src/providers/index.tsx","./src/types/file.ts","./src/types/playlist.ts","./src/types/track.ts","./src/stories/button.stories.ts","./src/stories/button.tsx","./src/stories/header.stories.ts","./src/stories/header.tsx","./src/stories/page.stories.ts","./src/stories/page.tsx"],"errors":true,"version":"5.6.2"}