Compare commits

..

144 Commits

Author SHA1 Message Date
jeffvli d289797d65 Add margin under image 2023-05-21 16:48:50 -07:00
jeffvli 6218b27117 Fix no-repeat on mpv (#55) 2023-05-21 16:43:47 -07:00
jeffvli 549db7b1bf Fix tooltip parent component 2023-05-21 16:03:25 -07:00
Kendall Garner 8ee99adb2d Fix full screen overflow (#113)
* fix text overflow making image take up too much space in full screen

* Fix missing key

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-05-21 16:01:37 -07:00
jeffvli da519c2250 Bump to version 0.1.0 2023-05-21 15:48:07 -07:00
jeffvli 7cd33ad388 Update github templates 2023-05-21 15:44:30 -07:00
jeffvli 8ae368ea4f Change artist display component 2023-05-21 15:36:15 -07:00
jeffvli 22e31b92a4 Update outline button styling 2023-05-21 15:35:23 -07:00
jeffvli a308efaf06 Fix jellyfin discography views (#81) 2023-05-21 15:34:52 -07:00
jeffvli 977cb89481 Add fullscreen player button tooltip 2023-05-21 15:09:32 -07:00
jeffvli 0c3b030b13 Add create playlist button on playlist list page 2023-05-21 15:02:57 -07:00
jeffvli 86080c7875 Revert paper bg 2023-05-21 08:14:30 -07:00
jeffvli b71c3c7c53 Handle song detail add 2023-05-21 08:13:48 -07:00
jeffvli debdb92dcf Add shuffle all feature 2023-05-21 07:33:22 -07:00
jeffvli ba6f2a1637 Fix left icon placement 2023-05-21 07:31:58 -07:00
jeffvli 7c6f62023a Fix song null check on queue add 2023-05-21 07:31:18 -07:00
jeffvli de50002ea7 Add random song list query 2023-05-21 07:30:28 -07:00
jeffvli 41a251c2ac Decrease toast durations 2023-05-21 07:18:41 -07:00
jeffvli 10d7664733 Add stop button to playerbar 2023-05-21 03:17:45 -07:00
jeffvli fed96d1fce Additional player adjustments
- Set volume on play
- Explicitly pause/play on set queue
2023-05-21 03:08:25 -07:00
Kendall Garner 106fc90c4a Add ability to save/restore queue (#111)
* add ability to save/restore play queue

* Add restoreQueue action

* Add optional pause param on setQueue

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-05-21 02:29:58 -07:00
jeffvli c1c6ce33e4 Fix type 2023-05-20 23:22:43 -07:00
jeffvli 26bc7d23ae Adjust default themes 2023-05-20 23:22:02 -07:00
jeffvli 30dc833b79 Add additional padding under home carousels 2023-05-20 23:13:57 -07:00
jeffvli 292737d53c Add query cancellation for play queue requests
- Opens a notification after 2s to allow for manual cancellation of in-progress query
2023-05-20 23:13:20 -07:00
jeffvli 652c4a1f81 Use play handler from context 2023-05-20 23:09:26 -07:00
jeffvli fb158bc069 Add types to query keys object 2023-05-20 22:40:22 -07:00
jeffvli 51c2731b07 Handle queue all songs by double click (#67) 2023-05-20 21:31:00 -07:00
jeffvli 93530008a9 Add custom query prop to play queue add 2023-05-20 20:58:11 -07:00
jeffvli 6747fbb701 Add initialSongId prop as alternative to initialIndex 2023-05-20 20:47:07 -07:00
jeffvli 06d253228a Fix normalized types 2023-05-20 20:22:10 -07:00
jeffvli c8b1b4d394 Update electron web preferences 2023-05-20 20:21:14 -07:00
jeffvli 0320fe6dcc Add mpv load error notification
- Add retry limit on error
2023-05-20 20:19:41 -07:00
jeffvli 1f36978bb9 Fix deprecated import 2023-05-20 20:00:09 -07:00
jeffvli 6a01d44600 Clean up mpv startup 2023-05-20 19:56:17 -07:00
jeffvli 35f9798bed Update full-width cell renderer 2023-05-20 19:21:23 -07:00
jeffvli 897af4661b Add extraProps param on column defs 2023-05-20 18:41:24 -07:00
jeffvli 3df2915f5f Allow initialIndex on queue add (#67)
- Clean up play queue handler
- Split out functions to utils
2023-05-20 18:40:45 -07:00
jeffvli 02caf896ff Update playqueueadd props 2023-05-20 14:55:08 -07:00
jeffvli 7dd56bfb9b Add --idle to default mpv parameters (#76) 2023-05-20 14:40:55 -07:00
jeffvli fe59011882 Fix conditionals on album artist detail 2023-05-20 02:26:24 -07:00
jeffvli c854fd0a5b Re-add artistInfo api call for navidrome 2023-05-20 02:24:40 -07:00
jeffvli 645b4fe332 Prevent error on attempt to context menu during render 2023-05-20 02:08:37 -07:00
jeffvli e5f24b3160 Set context menu based on item type 2023-05-20 02:08:37 -07:00
jeffvli fff1315fa5 Add search route 2023-05-20 02:08:37 -07:00
jeffvli ba0543f861 Fix type for addToQueue reducer 2023-05-20 02:08:37 -07:00
jeffvli 30c4d5baf1 Add param to search route to denote tab 2023-05-20 02:08:37 -07:00
jeffvli e8f7ae637f Add state to search route navigation 2023-05-20 02:08:37 -07:00
jeffvli b5fa6f0baa Handle song detail on playqueue add 2023-05-20 02:08:37 -07:00
jeffvli c4fb9a2e72 Add song detail controller 2023-05-20 02:08:37 -07:00
jeffvli 2cefc092ce Close window after selecting search item 2023-05-20 02:08:37 -07:00
jeffvli a7ea54cf4b Add jellyfin search api 2023-05-20 02:08:37 -07:00
jeffvli deb4e34895 Adjust styles 2023-05-20 02:08:37 -07:00
jeffvli 33ecf9faa6 Add command item breadcrumbs 2023-05-20 02:08:37 -07:00
jeffvli cf6325d0ba Decrease item padding 2023-05-20 02:08:37 -07:00
jeffvli c12c1bad73 Add library search 2023-05-20 02:08:37 -07:00
jeffvli cf9ed31dfd Updates to general commands 2023-05-20 02:08:37 -07:00
jeffvli c296927bbb Prevent render error on missing endpoint 2023-05-20 02:08:37 -07:00
jeffvli 32ebe6b739 Add subsonic/nd search api 2023-05-20 02:08:37 -07:00
jeffvli c85a7079eb Add handlers to open command palette 2023-05-20 02:08:37 -07:00
jeffvli 822060b82c Add base command palette component 2023-05-20 02:08:37 -07:00
jeffvli 547fe7be38 Add global search state 2023-05-20 02:08:37 -07:00
jeffvli ccf5588435 Add cmdk package 2023-05-20 02:08:37 -07:00
jeffvli 4cb54bc9da Fix misc types 2023-05-17 21:07:04 -07:00
jeffvli ce72ff5e8d Remove mpv quit effect
- Causing errors in dev build
2023-05-17 17:51:14 -07:00
jeffvli 71b9cace53 Add callback for swiper zoom change 2023-05-17 17:47:05 -07:00
jeffvli 5637327e8a Fix conditionals on jellyfin normalization 2023-05-17 17:38:49 -07:00
jeffvli a1072b461f Add inferred api types to controller 2023-05-17 17:38:13 -07:00
jeffvli 3fb24d5f64 Re-add infinite album list query 2023-05-17 17:26:23 -07:00
jeffvli e45252d16c Fix mpv sample rate setting
- Fix default input value
- Disable property on 0 value
2023-05-17 17:25:20 -07:00
jeffvli 48ef7a987f Add new swiper carousels to pages 2023-05-17 17:12:23 -07:00
jeffvli 58d912065b Add swiper card / update virt cards 2023-05-17 17:11:33 -07:00
jeffvli d8130f48e2 Add swiper carousel component 2023-05-17 17:10:30 -07:00
jeffvli 89afa9b836 Fix subsonic error result 2023-05-14 18:34:08 -07:00
jeffvli 684ba13175 Re-order app menu
- Move version number to menu
- Add link to github
2023-05-14 02:01:37 -07:00
jeffvli 2399105f6c Change dropdown item selection style 2023-05-14 02:00:23 -07:00
jeffvli d42f4dbe4f Add swiper package 2023-05-14 01:58:05 -07:00
jeffvli cf32a7ff21 Debounce hotkey set to improve performance 2023-05-14 01:57:42 -07:00
jeffvli 5eea3d7e01 Fix duplicate keys on grid skeletons 2023-05-13 23:09:20 -07:00
jeffvli e2e3a50f1f Add grid card indicator for favorite items 2023-05-13 23:06:02 -07:00
jeffvli 4c98afb613 Add hotkey controls to relevant pages 2023-05-13 22:55:58 -07:00
jeffvli d7f24262fd Add hotkeys manager
- Add configuration to settings store
- Initialize global hotkeys on startup from renderer
2023-05-13 22:55:58 -07:00
jeffvli 6056504f00 Add ipcRenderer send function to preload 2023-05-13 22:55:58 -07:00
jeffvli cef92243f5 Fix favorite mutation 2023-05-13 22:54:24 -07:00
jeffvli 8d5c82b0c6 Fix query array parser for navidrome api 2023-05-13 22:53:14 -07:00
jeffvli 003fb26c60 Add checkbox component 2023-05-11 02:51:00 -07:00
jeffvli 4eb90d20a2 Handle list auto size when vertical scroll appears 2023-05-11 01:58:04 -07:00
jeffvli cf489d3934 Fix types for updated packages 2023-05-10 20:00:39 -07:00
jeffvli 416476cc66 Set card image max height
- Fixes oversizing due to virtual grid
2023-05-10 19:54:45 -07:00
jeffvli bdc3daf6da Switch ND song list parameter to album_artist_id 2023-05-10 18:46:03 -07:00
jeffvli 129515d57a Fix deprecated import 2023-05-10 18:45:22 -07:00
jeffvli 76ca03d8e3 Remove shadow on playerbar 2023-05-10 18:21:34 -07:00
jeffvli e49fe6c452 Add collapsible sidebar (#68)
- Sidebar can collapse by menu option or dragging
2023-05-10 18:20:04 -07:00
jeffvli ec7a053a74 Remove text color transition 2023-05-10 18:13:24 -07:00
jeffvli 9e4e6172c3 Bump packages 2023-05-10 18:12:29 -07:00
jeffvli eca26e912f Set card images to cover
- Better UX since it makes the grid look more consistent
2023-05-10 14:34:06 -07:00
jeffvli f9e410a1f5 Set fullscreen player over right sidebar 2023-05-10 03:08:55 -07:00
jeffvli 87abd0c6f5 Fix subsonic params parser 2023-05-09 19:33:46 -07:00
jeffvli e3665e6407 Adjust jellyfin types to include additional properties 2023-05-09 18:58:24 -07:00
jeffvli c87905f6c2 Set auto_restart prop to true on mpv instance 2023-05-09 18:55:54 -07:00
jeffvli 2100c1495d Improve grid card components
- Dynamic placeholder depending on item type
- Fix skeleton for default card
2023-05-09 18:55:26 -07:00
jeffvli b5da8aeb55 Remove skeleton animation
- Performance concerns due to large number of animated skeletons
2023-05-09 18:51:26 -07:00
jeffvli 5eeded6c72 Fix fallback to album image for Jellyfin (#97) 2023-05-09 12:01:51 -07:00
jeffvli 346b8be122 Fix JF discography view (#81) 2023-05-09 11:06:01 -07:00
jeffvli a19673d3c2 Replace mutation error types with AxiosError 2023-05-09 05:53:57 -07:00
jeffvli 3efeaa7359 Improve multi-server controller 2023-05-09 05:49:05 -07:00
jeffvli 63be8c8fb8 Add authenticate function to controller 2023-05-09 05:48:11 -07:00
jeffvli 975c31635a Remove old API implementation 2023-05-09 05:45:55 -07:00
jeffvli 9b5bce34a0 Fix jellyfin auth endpoint 2023-05-09 05:06:32 -07:00
jeffvli bb27758310 Re-serialize subsonic array params 2023-05-09 05:05:15 -07:00
jeffvli 2d7c52a6b6 Improve UX for edit server form
- Auto focus the password field on edit server form
- Don't disable save button when fields blank
- Add tooltip for modified fields
2023-05-09 02:40:49 -07:00
jeffvli cbb15ac7ee Fix various issues 2023-05-09 02:25:57 -07:00
jeffvli b2db2b27da Refactor server list to object instead of array
- Improve performance due to frequency of accessing the list
2023-05-09 00:39:11 -07:00
jeffvli 3dfeed1432 Invalidate playlist list on creation 2023-05-08 03:35:51 -07:00
jeffvli 2101f1e9a7 Fix legacy normalizations 2023-05-08 03:35:23 -07:00
jeffvli 8a0a8e4d54 Refactor jellyfin api with ts-rest/axios 2023-05-08 03:34:15 -07:00
jeffvli a9ca3f9083 Add additional undefined check for custom filters 2023-05-08 03:33:38 -07:00
jeffvli 6d5e10a31c Add albumCount and songCount to genre 2023-05-08 02:42:38 -07:00
jeffvli 5b616d5928 Update initial list store filters 2023-04-30 22:53:11 -07:00
jeffvli 62670964c0 Add menu in error boundary 2023-04-30 22:05:06 -07:00
jeffvli 314bd766df Refactor all api instances in components 2023-04-30 22:01:52 -07:00
jeffvli bdd023fde3 Refactor remaining queries/mutations for new controller 2023-04-30 18:00:50 -07:00
jeffvli 40aabd2217 Additional refactor for navidrome api controller types 2023-04-30 17:55:23 -07:00
jeffvli b9d5447b4f Allow serverId to be undefined 2023-04-27 22:20:35 -07:00
jeffvli 68a1cb9aaa Refactor all mutation hooks 2023-04-27 21:44:25 -07:00
jeffvli bf3024939a Refactor all query hooks 2023-04-27 21:25:57 -07:00
jeffvli df9464f762 Additional refactor to api and types 2023-04-27 20:34:28 -07:00
jeffvli 17cf624f6a Add generic query/mutation types 2023-04-27 20:32:56 -07:00
jeffvli 8f042ad448 Pass full server to controller 2023-04-25 16:25:26 -07:00
jeffvli 1cbd61888f Refactor server list as hash table 2023-04-25 01:36:26 -07:00
jeffvli 2ce49fc54e Add new server api to main controller 2023-04-24 01:22:58 -07:00
jeffvli bec328f1f4 Add Subsonic API and types 2023-04-24 01:21:29 -07:00
jeffvli ea8c63b71b Add new navidrome api controller 2023-04-23 19:57:10 -07:00
jeffvli 52049ce163 Add missing elements from Navidrome API 2023-04-23 19:54:36 -07:00
jeffvli 70c62c8b52 Refactor api client to support dynamic server 2023-04-23 14:26:41 -07:00
jeffvli fa79b4cbe0 Fix artist path 2023-04-23 14:25:09 -07:00
jeffvli 438085633b Modify navidrome responses to include header 2023-04-23 02:09:48 -07:00
jeffvli fe043d1823 Add function to modify base response 2023-04-23 02:09:25 -07:00
jeffvli 9bd12df8f6 Add navidrome API and types 2023-04-23 01:39:47 -07:00
jeffvli 637d420e1c Add ts-rest and axios 2023-04-23 01:25:16 -07:00
jeffvli c593b7bc46 Fix slider styles to account for transparent thumb (#85) 2023-04-20 01:54:51 -07:00
jeffvli 5e90139b17 Fix styles from mantine upgrade 2023-04-20 01:47:42 -07:00
jeffvli ed86d8ffd2 Bump mantine to v6.0.8 2023-04-20 01:45:27 -07:00
jeffvli bcaaaac586 Set auto-update as default 2023-04-03 18:26:56 -07:00
193 changed files with 11877 additions and 5762 deletions
+2
View File
@@ -1 +1,3 @@
# These are supported funding model platforms
ko_fi: jeffvli
+3 -2
View File
@@ -39,6 +39,7 @@ labels: 'bug'
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version :
- Operating System and version :
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :
@@ -7,3 +7,5 @@ labels: 'enhancement'
## What do you want to be added?
## Additional context
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
Regular → Executable
View File
+713 -283
View File
File diff suppressed because it is too large Load Diff
+23 -21
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.0.1-alpha6",
"version": "0.1.0",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -254,16 +254,18 @@
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.0",
"@mantine/dates": "^6.0.0",
"@mantine/dropzone": "^6.0.0",
"@mantine/form": "^6.0.0",
"@mantine/hooks": "^6.0.0",
"@mantine/modals": "^6.0.0",
"@mantine/notifications": "^6.0.0",
"@mantine/utils": "^6.0.0",
"@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.24.4",
"@mantine/core": "^6.0.10",
"@mantine/dates": "^6.0.10",
"@mantine/form": "^6.0.10",
"@mantine/hooks": "^6.0.10",
"@mantine/modals": "^6.0.10",
"@mantine/notifications": "^6.0.10",
"@mantine/utils": "^6.0.10",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@ts-rest/core": "^3.19.4",
"axios": "^1.4.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
@@ -275,9 +277,8 @@
"framer-motion": "^8.1.3",
"history": "^5.3.0",
"i18next": "^21.6.16",
"immer": "^9.0.15",
"is-electron": "^2.2.1",
"ky": "^0.33.0",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
@@ -288,17 +289,18 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-icons": "^4.7.1",
"react-icons": "^4.8.0",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.8",
"react-window-infinite-loader": "^1.0.8",
"styled-components": "^5.3.6",
"zod": "^3.19.1",
"zustand": "^4.1.4"
"react-virtualized-auto-sizer": "^1.0.15",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.10",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"resolutions": {
"styled-components": "^5"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "feishin",
"version": "0.0.1-alpha6",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.0.1-alpha6",
"version": "0.1.0",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.0.1-alpha6",
"version": "0.1.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
+27 -18
View File
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron';
import { getMpvInstance } from '../../../main';
import { getMainWindow, getMpvInstance } from '../../../main';
import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv';
@@ -52,7 +52,7 @@ ipcMain.on('player-seek-to', async (_event, time: number) => {
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
@@ -60,23 +60,36 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
}
let complete = false;
let tryAttempts = 0;
while (!complete) {
try {
if (data.queue.current) {
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
}
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
if (tryAttempts > 3) {
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
complete = true;
} catch (err) {
console.error(err);
await wait(500);
} else {
try {
if (data.queue.current) {
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
}
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
complete = true;
} catch (err) {
console.error(err);
tryAttempts += 1;
await wait(500);
}
}
}
if (pause) {
await getMpvInstance()?.pause();
} else {
await getMpvInstance()?.play();
}
});
// Replaces the queue in position 1 to the given data
@@ -117,7 +130,3 @@ ipcMain.on('player-volume', async (_event, value: number) => {
ipcMain.on('player-mute', async () => {
await getMpvInstance()?.mute();
});
ipcMain.on('player-quit', async () => {
await getMpvInstance()?.stop();
});
+189 -14
View File
@@ -8,7 +8,9 @@
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import {
app,
BrowserWindow,
@@ -27,7 +29,7 @@ import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index';
import MenuBuilder from './menu';
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import './features';
declare module 'node-mpv';
@@ -97,7 +99,6 @@ export const getMainWindow = () => {
const createWinThumbarButtons = () => {
if (isWindows()) {
console.log('setting buttons');
getMainWindow()?.setThumbarButtons([
{
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
@@ -190,6 +191,7 @@ const createWindow = async () => {
minWidth: 640,
show: false,
webPreferences: {
allowRunningInsecureContent: !!store.get('ignore_ssl'),
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
@@ -197,7 +199,7 @@ const createWindow = async () => {
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
webSecurity: store.get('ignore_cors') ? false : undefined,
webSecurity: !store.get('ignore_cors'),
},
width: 1440,
});
@@ -239,6 +241,36 @@ const createWindow = async () => {
disableMediaKeys();
});
ipcMain.on('player-restore-queue', () => {
if (store.get('resume')) {
const queueLocation = join(app.getPath('userData'), 'queue');
access(queueLocation, constants.F_OK, (accessError) => {
if (accessError) {
console.error('unable to access saved queue: ', accessError);
return;
}
readFile(queueLocation, (readError, buffer) => {
if (readError) {
console.error('failed to read saved queue: ', readError);
return;
}
inflate(buffer, (decompressError, data) => {
if (decompressError) {
console.error('failed to decompress queue: ', decompressError);
return;
}
const queue = JSON.parse(data.toString());
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
});
});
});
}
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
@@ -263,6 +295,8 @@ const createWindow = async () => {
mainWindow = null;
});
let saved = false;
mainWindow.on('close', (event) => {
if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) {
@@ -271,6 +305,43 @@ const createWindow = async () => {
event.preventDefault();
mainWindow?.hide();
}
if (!saved && store.get('resume')) {
event.preventDefault();
saved = true;
getMainWindow()?.webContents.send('renderer-player-save-queue');
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
const queueLocation = join(app.getPath('userData'), 'queue');
const serialized = JSON.stringify(data);
try {
await new Promise<void>((resolve, reject) => {
deflate(serialized, { level: 1 }, (error, deflated) => {
if (error) {
reject(error);
} else {
writeFile(queueLocation, deflated, (writeError) => {
if (writeError) {
reject(writeError);
} else {
resolve();
}
});
}
});
});
} catch (error) {
console.error('error saving queue state: ', error);
} finally {
mainWindow?.close();
if (forceQuit) {
app.exit();
}
}
});
}
});
mainWindow.on('minimize', (event: any) => {
@@ -308,7 +379,6 @@ const createWindow = async () => {
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
@@ -316,10 +386,10 @@ const prefetchPlaylistParams = [
'--prefetch-playlist',
];
const DEFAULT_MPV_PARAMETERS = () => {
const parameters = [];
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = ['--idle=yes'];
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes');
}
@@ -331,26 +401,33 @@ let mpvInstance: MpvAPI | null = null;
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
const { extraParameters, properties } = data;
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
console.log('Setting mpv params: ', params);
mpvInstance = new MpvAPI(
{
audio_only: true,
auto_restart: false,
auto_restart: true,
binary: MPV_BINARY_PATH || '',
time_update: 1,
},
MPV_PARAMETERS || extraParameters
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
: DEFAULT_MPV_PARAMETERS(),
params,
);
console.log('Setting MPV properties: ', properties);
mpvInstance.setMultipleProperties(properties || {});
mpvInstance.start().catch((error) => {
console.log('error starting mpv', error);
console.log('MPV Event: start error', error);
});
mpvInstance.on('status', (status) => {
mpvInstance.on('status', (status, ...rest) => {
console.log('MPV Event: status', status.property, status.value, rest);
if (status.property === 'playlist-pos') {
if (status.value === -1) {
mpvInstance?.stop();
}
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
@@ -359,16 +436,19 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
// Automatically updates the play button when the player is playing
mpvInstance.on('resumed', () => {
console.log('MPV Event: resumed');
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpvInstance.on('stopped', () => {
console.log('MPV Event: stopped');
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpvInstance.on('paused', () => {
console.log('MPV Event: paused');
getMainWindow()?.webContents.send('renderer-player-pause');
});
@@ -376,6 +456,10 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
mpvInstance.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time);
});
mpvInstance.on('quit', () => {
console.log('MPV Event: quit');
});
};
export const getMpvInstance = () => {
@@ -402,6 +486,97 @@ ipcMain.on(
},
);
ipcMain.on(
'player-initialize',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
createMpv(data);
},
);
ipcMain.on('player-quit', async () => {
mpvInstance?.stop();
mpvInstance?.quit();
mpvInstance = null;
});
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NEXT = 'next',
PAUSE = 'pause',
PLAY = 'play',
PLAY_PAUSE = 'playPause',
PREVIOUS = 'previous',
SHUFFLE = 'toggleShuffle',
SKIP_BACKWARD = 'skipBackward',
SKIP_FORWARD = 'skipForward',
STOP = 'stop',
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
TOGGLE_QUEUE = 'toggleQueue',
TOGGLE_REPEAT = 'toggleRepeat',
VOLUME_DOWN = 'volumeDown',
VOLUME_UP = 'volumeUp',
}
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
[BindingActions.PLAY_PAUSE]: () =>
getMainWindow()?.webContents.send('renderer-player-play-pause'),
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
[BindingActions.SHUFFLE]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
[BindingActions.SKIP_BACKWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
[BindingActions.SKIP_FORWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
[BindingActions.TOGGLE_REPEAT]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
[BindingActions.VOLUME_UP]: () => getMainWindow()?.webContents.send('renderer-player-volume-up'),
[BindingActions.VOLUME_DOWN]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-down'),
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
[BindingActions.TOGGLE_QUEUE]: () => {},
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
};
ipcMain.on(
'set-global-shortcuts',
(
_event,
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
) => {
// Since we're not tracking the previous shortcuts, we need to unregister all of them
globalShortcut.unregisterAll();
for (const shortcut of Object.keys(data)) {
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
const isValidHotkey =
data[shortcut as BindingActions].hotkey && data[shortcut as BindingActions].hotkey !== '';
if (isGlobalHotkey && isValidHotkey) {
const accelerator = hotkeyToElectronAccelerator(data[shortcut as BindingActions].hotkey);
globalShortcut.register(accelerator, () => {
HOTKEY_ACTIONS[shortcut as BindingActions]();
});
}
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
},
);
app.on('before-quit', () => {
getMpvInstance()?.stop();
});
+5
View File
@@ -4,6 +4,11 @@ const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel);
};
const send = (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
};
export const ipc = {
removeAllListeners,
send,
};
+67 -2
View File
@@ -1,6 +1,10 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data);
};
@@ -38,6 +42,14 @@ const previous = () => {
ipcRenderer.send('player-previous');
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
};
@@ -46,8 +58,8 @@ const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData) => {
ipcRenderer.send('player-set-queue', data);
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
};
const setQueueNext = (data: PlayerData) => {
@@ -98,13 +110,54 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) =
ipcRenderer.on('renderer-player-stop', cb);
};
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
};
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
};
export const mpvPlayer = {
autoNext,
currentTime,
initialize,
mute,
next,
pause,
@@ -112,6 +165,8 @@ export const mpvPlayer = {
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
@@ -124,11 +179,21 @@ export const mpvPlayer = {
export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
};
+21
View File
@@ -29,3 +29,24 @@ export const isWindows = () => {
export const isLinux = () => {
return process.platform === 'linux';
};
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
return accelerator;
};
+300 -146
View File
@@ -1,86 +1,95 @@
import { useAuthStore } from '/@/renderer/store';
import { navidromeApi } from '/@/renderer/api/navidrome.api';
import { toast } from '/@/renderer/components/toast';
import { toast } from '/@/renderer/components/toast/index';
import type {
AlbumDetailArgs,
RawAlbumDetailResponse,
RawAlbumListResponse,
AlbumListArgs,
SongListArgs,
RawSongListResponse,
SongDetailArgs,
RawSongDetailResponse,
AlbumArtistDetailArgs,
RawAlbumArtistDetailResponse,
AlbumArtistListArgs,
RawAlbumArtistListResponse,
RatingArgs,
RawRatingResponse,
RawFavoriteResponse,
SetRatingArgs,
GenreListArgs,
RawGenreListResponse,
CreatePlaylistArgs,
RawCreatePlaylistResponse,
DeletePlaylistArgs,
RawDeletePlaylistResponse,
PlaylistDetailArgs,
RawPlaylistDetailResponse,
PlaylistListArgs,
RawPlaylistListResponse,
MusicFolderListArgs,
RawMusicFolderListResponse,
PlaylistSongListArgs,
ArtistListArgs,
RawArtistListResponse,
UpdatePlaylistArgs,
RawUpdatePlaylistResponse,
UserListArgs,
RawUserListResponse,
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
AddToPlaylistArgs,
RawAddToPlaylistResponse,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RawRemoveFromPlaylistResponse,
RemoveFromPlaylistResponse,
ScrobbleArgs,
RawScrobbleResponse,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
SearchArgs,
SearchResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
import { ServerListItem } from '/@/renderer/types';
import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<RawDeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<RawAlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<RawAlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
getFolderList: () => void;
getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<RawGenreListResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<RawMusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<RawPlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<RawPlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
type ApiController = {
@@ -91,48 +100,17 @@ type ApiController = {
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jellyfinApi.addToPlaylist,
addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate,
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
deleteFavorite: jellyfinApi.deleteFavorite,
deletePlaylist: jellyfinApi.deletePlaylist,
getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail,
getAlbumArtistList: jellyfinApi.getAlbumArtistList,
getAlbumDetail: jellyfinApi.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: jellyfinApi.getArtistList,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: jellyfinApi.getGenreList,
getMusicFolderList: jellyfinApi.getMusicFolderList,
getPlaylistDetail: jellyfinApi.getPlaylistDetail,
getPlaylistList: jellyfinApi.getPlaylistList,
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getTopSongs: jellyfinApi.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
scrobble: jellyfinApi.scrobble,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
navidrome: {
addToPlaylist: navidromeApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: navidromeApi.createPlaylist,
deleteFavorite: subsonicApi.deleteFavorite,
deletePlaylist: navidromeApi.deletePlaylist,
getAlbumArtistDetail: navidromeApi.getAlbumArtistDetail,
getAlbumArtistList: navidromeApi.getAlbumArtistList,
getAlbumDetail: navidromeApi.getAlbumDetail,
getAlbumList: navidromeApi.getAlbumList,
createFavorite: jfController.createFavorite,
createPlaylist: jfController.createPlaylist,
deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jfController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
@@ -140,30 +118,68 @@ const endpoints: ApiController = {
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: navidromeApi.getGenreList,
getMusicFolderList: subsonicApi.getMusicFolderList,
getPlaylistDetail: navidromeApi.getPlaylistDetail,
getPlaylistList: navidromeApi.getPlaylistList,
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
scrobble: subsonicApi.scrobble,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
getGenreList: jfController.getGenreList,
getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined,
getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined,
updatePlaylist: jfController.updatePlaylist,
},
navidrome: {
addToPlaylist: ndController.addToPlaylist,
authenticate: ndController.authenticate,
clearPlaylist: undefined,
createFavorite: ssController.createFavorite,
createPlaylist: ndController.createPlaylist,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: ndController.deletePlaylist,
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
getAlbumArtistList: ndController.getAlbumArtistList,
getAlbumDetail: ndController.getAlbumDetail,
getAlbumList: ndController.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: ndController.getGenreList,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist,
},
subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createFavorite: ssController.createFavorite,
createPlaylist: undefined,
deleteFavorite: subsonicApi.deleteFavorite,
deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined,
getAlbumArtistDetail: subsonicApi.getAlbumArtistDetail,
getAlbumArtistList: subsonicApi.getAlbumArtistList,
getAlbumDetail: subsonicApi.getAlbumDetail,
getAlbumList: subsonicApi.getAlbumList,
getAlbumArtistDetail: undefined,
getAlbumArtistList: undefined,
getAlbumDetail: undefined,
getAlbumList: undefined,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
@@ -172,134 +188,269 @@ const endpoints: ApiController = {
getFolderList: undefined,
getFolderSongs: undefined,
getGenreList: undefined,
getMusicFolderList: subsonicApi.getMusicFolderList,
getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined,
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: subsonicApi.getTopSongList,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: subsonicApi.scrobble,
scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined,
updatePlaylist: undefined,
updateRating: undefined,
},
};
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
return () => undefined;
throw new Error(`No server selected`);
}
const controllerFn = endpoints[serverType][endpoint];
const controllerFn = endpoints?.[serverType]?.[endpoint];
if (typeof controllerFn !== 'function') {
toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request',
});
return () => undefined;
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
}
return endpoints[serverType][endpoint];
};
const authenticate = async (
url: string,
body: { legacy?: boolean; password: string; username: string },
type: ServerType,
) => {
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
};
const getAlbumList = async (args: AlbumListArgs) => {
return (apiController('getAlbumList') as ControllerEndpoint['getAlbumList'])?.(args);
return (
apiController(
'getAlbumList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumList']
)?.(args);
};
const getAlbumDetail = async (args: AlbumDetailArgs) => {
return (apiController('getAlbumDetail') as ControllerEndpoint['getAlbumDetail'])?.(args);
return (
apiController(
'getAlbumDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumDetail']
)?.(args);
};
const getSongList = async (args: SongListArgs) => {
return (apiController('getSongList') as ControllerEndpoint['getSongList'])?.(args);
return (
apiController(
'getSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList']
)?.(args);
};
const getSongDetail = async (args: SongDetailArgs) => {
return (
apiController(
'getSongDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail']
)?.(args);
};
const getMusicFolderList = async (args: MusicFolderListArgs) => {
return (apiController('getMusicFolderList') as ControllerEndpoint['getMusicFolderList'])?.(args);
return (
apiController(
'getMusicFolderList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getMusicFolderList']
)?.(args);
};
const getGenreList = async (args: GenreListArgs) => {
return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args);
return (
apiController(
'getGenreList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getGenreList']
)?.(args);
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
return (apiController('getAlbumArtistDetail') as ControllerEndpoint['getAlbumArtistDetail'])?.(
args,
);
return (
apiController(
'getAlbumArtistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args);
return (
apiController(
'getAlbumArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistList']
)?.(args);
};
const getArtistList = async (args: ArtistListArgs) => {
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
return (
apiController(
'getArtistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getArtistList']
)?.(args);
};
const getPlaylistList = async (args: PlaylistListArgs) => {
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
return (
apiController(
'getPlaylistList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistList']
)?.(args);
};
const createPlaylist = async (args: CreatePlaylistArgs) => {
return (apiController('createPlaylist') as ControllerEndpoint['createPlaylist'])?.(args);
return (
apiController(
'createPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createPlaylist']
)?.(args);
};
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args);
return (
apiController(
'updatePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['updatePlaylist']
)?.(args);
};
const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
return (
apiController(
'deletePlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deletePlaylist']
)?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
return (
apiController(
'addToPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
return (
apiController(
'getPlaylistDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistDetail']
)?.(args);
};
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.(
args,
);
return (
apiController(
'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
};
const getUserList = async (args: UserListArgs) => {
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (apiController('createFavorite') as ControllerEndpoint['createFavorite'])?.(args);
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (apiController('deleteFavorite') as ControllerEndpoint['deleteFavorite'])?.(args);
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(args);
};
const updateRating = async (args: RatingArgs) => {
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
const updateRating = async (args: SetRatingArgs) => {
return (
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
)?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
return (
apiController(
'getTopSongs',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
return (
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
)?.(args);
};
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
const getRandomSongList = async (args: RandomSongListArgs) => {
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
};
export const controller = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
@@ -314,11 +465,14 @@ export const controller = {
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getSongDetail,
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
search,
updatePlaylist,
updateRating,
};
-2
View File
@@ -1,7 +1,5 @@
import { controller } from '/@/renderer/api/controller';
import { normalize } from '/@/renderer/api/normalize';
export const api = {
controller,
normalize,
};
File diff suppressed because it is too large Load Diff
+345
View File
@@ -0,0 +1,345 @@
import { useAuthStore } from '/@/renderer/store';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { toast } from '/@/renderer/components';
import { ServerListItem } from '/@/renderer/types';
import omitBy from 'lodash/omitBy';
const c = initContract();
export const contract = c.router({
addToPlaylist: {
body: jfType._parameters.addToPlaylist,
method: 'POST',
path: 'playlists/:id/items',
responses: {
200: jfType._response.addToPlaylist,
400: jfType._response.error,
},
},
authenticate: {
body: jfType._parameters.authenticate,
method: 'POST',
path: 'users/authenticatebyname',
responses: {
200: jfType._response.authenticate,
400: jfType._response.error,
},
},
createFavorite: {
body: jfType._parameters.favorite,
method: 'POST',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
},
},
createPlaylist: {
body: jfType._parameters.createPlaylist,
method: 'POST',
path: 'playlists',
responses: {
200: jfType._response.createPlaylist,
400: jfType._response.error,
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'items/:id',
responses: {
204: jfType._response.deletePlaylist,
400: jfType._response.error,
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumArtistDetail,
responses: {
200: jfType._response.albumArtist,
400: jfType._response.error,
},
},
getAlbumArtistList: {
method: 'GET',
path: 'artists/albumArtists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
getAlbumDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.albumDetail,
responses: {
200: jfType._response.album,
400: jfType._response.error,
},
},
getAlbumList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.albumList,
responses: {
200: jfType._response.albumList,
400: jfType._response.error,
},
},
getArtistList: {
method: 'GET',
path: 'artists',
query: jfType._parameters.albumArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
getGenreList: {
method: 'GET',
path: 'genres',
responses: {
200: jfType._response.genreList,
400: jfType._response.error,
},
},
getMusicFolderList: {
method: 'GET',
path: 'users/:userId/items',
responses: {
200: jfType._response.musicFolderList,
400: jfType._response.error,
},
},
getPlaylistDetail: {
method: 'GET',
path: 'users/:userId/items/:id',
query: jfType._parameters.playlistDetail,
responses: {
200: jfType._response.playlist,
400: jfType._response.error,
},
},
getPlaylistList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.playlistList,
responses: {
200: jfType._response.playlistList,
400: jfType._response.error,
},
},
getPlaylistSongList: {
method: 'GET',
path: 'playlists/:id/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.playlistSongList,
400: jfType._response.error,
},
},
getSimilarArtistList: {
method: 'GET',
path: 'artists/:id/similar',
query: jfType._parameters.similarArtistList,
responses: {
200: jfType._response.albumArtistList,
400: jfType._response.error,
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: jfType._response.song,
400: jfType._response.error,
},
},
getSongList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.songList,
400: jfType._response.error,
},
},
getTopSongsList: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.songList,
responses: {
200: jfType._response.topSongsList,
400: jfType._response.error,
},
},
removeFavorite: {
body: jfType._parameters.favorite,
method: 'DELETE',
path: 'users/:userId/favoriteitems/:id',
responses: {
200: jfType._response.favorite,
400: jfType._response.error,
},
},
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'items/:id',
query: jfType._parameters.removeFromPlaylist,
responses: {
200: jfType._response.removeFromPlaylist,
400: jfType._response.error,
},
},
scrobblePlaying: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
scrobbleProgress: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/progress',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
scrobbleStopped: {
body: jfType._parameters.scrobble,
method: 'POST',
path: 'sessions/playing/stopped',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
search: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.search,
responses: {
200: jfType._response.search,
400: jfType._response.error,
},
},
updatePlaylist: {
body: jfType._parameters.updatePlaylist,
method: 'PUT',
path: 'items/:id',
responses: {
200: jfType._response.updatePlaylist,
400: jfType._response.error,
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.credential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.setCurrentServer(null);
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
}
}
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
};
export const jfApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server?.url}`;
token = server?.credential;
} else {
baseUrl = url;
}
try {
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'X-MediaBrowser-Token': token }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: result.data,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: response.data,
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
};
@@ -0,0 +1,872 @@
import {
AuthenticationResponse,
MusicFolderListArgs,
MusicFolderListResponse,
GenreListArgs,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
albumArtistListSortMap,
sortOrderMap,
ArtistListArgs,
artistListSortMap,
AlbumDetailArgs,
AlbumListArgs,
albumListSortMap,
TopSongListArgs,
SongListArgs,
songListSortMap,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
PlaylistDetailArgs,
PlaylistSongListArgs,
PlaylistListArgs,
playlistListSortMap,
CreatePlaylistArgs,
CreatePlaylistResponse,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
DeletePlaylistArgs,
FavoriteArgs,
FavoriteResponse,
ScrobbleArgs,
ScrobbleResponse,
GenreListResponse,
AlbumArtistDetailResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
SongListResponse,
AddToPlaylistResponse,
RemoveFromPlaylistResponse,
PlaylistDetailResponse,
PlaylistListResponse,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const authenticate = async (
url: string,
body: {
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({
body: {
Pw: body.password,
Username: body.username,
},
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
},
});
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
return {
credential: res.body.AccessToken,
userId: res.body.User.Id,
username: res.body.User.Name,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
const { apiClientProps } = args;
const userId = apiClientProps.server?.userId;
if (!userId) throw new Error('No userId found');
const res = await jfApiClient(apiClientProps).getMusicFolderList({
params: {
userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
const musicFolders = res.body.Items.filter(
(folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
);
return {
items: musicFolders.map(jfNormalize.musicFolder),
startIndex: 0,
totalRecordCount: musicFolders?.length || 0,
};
};
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getGenreList();
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.Items.map(jfNormalize.genre),
startIndex: 0,
totalRecordCount: res.body?.Items?.length || 0,
};
};
const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
): Promise<AlbumArtistDetailResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
params: {
id: query.id,
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, Overview',
},
});
const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
params: {
id: query.id,
},
query: {
Limit: 10,
},
});
if (res.status !== 200 || similarArtistsRes.status !== 200) {
throw new Error('Failed to get album artist detail');
}
return jfNormalize.albumArtist(
{ ...res.body, similarArtists: similarArtistsRes.body },
apiClientProps.server,
);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
ImageTypeLimit: 1,
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId || undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
return {
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
};
const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist list');
}
return {
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getAlbumDetail({
params: {
id: query.id,
userId: apiClientProps.server.userId,
},
query: {
Fields: 'Genres, DateCreated, ChildCount',
},
});
const songsRes = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
ParentId: query.id,
SortBy: 'Album,SortName',
},
});
if (res.status !== 200 || songsRes.status !== 200) {
throw new Error('Failed to get album detail');
}
return jfNormalize.album({ ...res.body, Songs: songsRes.body.Items }, apiClientProps.server);
};
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
for (
let i = Number(query._custom?.jellyfin?.minYear);
i <= Number(query._custom?.jellyfin?.maxYear);
i += 1
) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
const res = await jfApiClient(apiClientProps).getAlbumList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumArtistIds: query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined,
IncludeItemTypes: 'MusicAlbum',
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
return {
items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
};
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getTopSongsList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
ArtistIds: query.artistId,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
Limit: query.limit,
Recursive: true,
SortBy: 'CommunityRating,SortName',
SortOrder: 'Descending',
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get top song list');
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
};
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
for (
let i = Number(query._custom?.jellyfin?.minYear);
i <= Number(query._custom?.jellyfin?.maxYear);
i += 1
) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
AlbumIds: albumIdsFilter,
ArtistIds: artistIdsFilter,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SearchTerm: query.searchTerm,
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
...query._custom?.jellyfin,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
const { query, body, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).addToPlaylist({
body: {
Ids: body.songId,
UserId: apiClientProps?.server?.userId,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to add to playlist');
}
return null;
};
const removeFromPlaylist = async (
args: RemoveFromPlaylistArgs,
): Promise<RemoveFromPlaylistResponse> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
EntryIds: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to remove from playlist');
}
return null;
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
Ids: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist detail');
}
return jfNormalize.playlist(res.body, apiClientProps.server);
};
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
IncludeItemTypes: 'Audio',
Limit: query.limit,
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
StartIndex: 0,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist song list');
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount,
};
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getPlaylistList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
IncludeItemTypes: 'Playlist',
Limit: query.limit,
MediaTypes: 'Audio',
Recursive: true,
SortBy: playlistListSortMap.jellyfin[query.sortBy],
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist list');
}
return {
items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),
startIndex: 0,
totalRecordCount: res.body.TotalRecordCount,
};
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { body, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).createPlaylist({
body: {
MediaType: 'Audio',
Name: body.name,
Overview: body.comment || '',
UserId: apiClientProps.server.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to create playlist');
}
return {
id: res.body.Id,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).updatePlaylist({
body: {
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
MediaType: 'Audio',
Name: body.name,
Overview: body.comment || '',
PremiereDate: null,
ProviderIds: {},
Tags: [],
UserId: apiClientProps.server?.userId, // Required
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist');
}
return null;
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
const { query, apiClientProps } = args;
const res = await jfApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
});
if (res.status !== 204) {
throw new Error('Failed to delete playlist');
}
return null;
};
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
for (const id of query.id) {
await jfApiClient(apiClientProps).createFavorite({
body: {},
params: {
id,
userId: apiClientProps.server?.userId,
},
});
}
return null;
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
for (const id of query.id) {
await jfApiClient(apiClientProps).removeFavorite({
body: {},
params: {
id,
userId: apiClientProps.server?.userId,
},
});
}
return null;
};
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
const { query, apiClientProps } = args;
const position = query.position && Math.round(query.position);
if (query.submission) {
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
jfApiClient(apiClientProps).scrobbleStopped({
body: {
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
});
return null;
}
if (query.event === 'start') {
jfApiClient(apiClientProps).scrobblePlaying({
body: {
ItemId: query.id,
PositionTicks: position,
},
});
return null;
}
if (query.event === 'pause') {
jfApiClient(apiClientProps).scrobbleProgress({
body: {
EventName: query.event,
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
});
return null;
}
if (query.event === 'unpause') {
jfApiClient(apiClientProps).scrobbleProgress({
body: {
EventName: query.event,
IsPaused: false,
ItemId: query.id,
PositionTicks: position,
},
});
return null;
}
jfApiClient(apiClientProps).scrobbleProgress({
body: {
ItemId: query.id,
PositionTicks: position,
},
});
return null;
};
const search = async (args: SearchArgs): Promise<SearchResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
let songs: z.infer<typeof jfType._response.songList>['Items'] = [];
if (query.albumLimit) {
const res = await jfApiClient(apiClientProps).getAlbumList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
EnableTotalRecordCount: true,
ImageTypeLimit: 1,
IncludeItemTypes: 'MusicAlbum',
Limit: query.albumLimit,
Recursive: true,
SearchTerm: query.query,
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: query.albumStartIndex || 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
albums = res.body.Items;
}
if (query.albumArtistLimit) {
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
ImageTypeLimit: 1,
IncludeArtists: true,
Limit: query.albumArtistLimit,
Recursive: true,
SearchTerm: query.query,
StartIndex: query.albumArtistStartIndex || 0,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
albumArtists = res.body.Items;
}
if (query.songLimit) {
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
Limit: query.songLimit,
Recursive: true,
SearchTerm: query.query,
SortBy: 'Album,SortName',
SortOrder: 'Ascending',
StartIndex: query.songStartIndex || 0,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
songs = res.body.Items;
}
return {
albumArtists: albumArtists.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
};
};
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query.minYear && query.maxYear) {
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genre ? query.genre : undefined,
IncludeItemTypes: 'Audio',
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SortBy: JFSongListSort.RANDOM,
SortOrder: JFSortOrder.ASC,
StartIndex: 0,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: 0,
totalRecordCount: res.body.Items.length || 0,
};
};
export const jfController = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getRandomSongList,
getSongList,
getTopSongList,
removeFromPlaylist,
scrobble,
search,
updatePlaylist,
};
@@ -0,0 +1,368 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import {
Song,
LibraryItem,
Album,
AlbumArtist,
Playlist,
MusicFolder,
Genre,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: {
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem | null;
}) => {
const { id, server, deviceId } = args;
return (
`${server?.url}/audio` +
`/${id}/universal` +
`?userId=${server?.userId}` +
`&deviceId=${deviceId}` +
'&audioCodec=aac' +
`&api_key=${server?.credential}` +
`&playSessionId=${deviceId}` +
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
'&transcodingContainer=ts' +
'&transcodingProtocol=hls'
);
};
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const getSongCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
}) => {
const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
return null;
};
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
albumId: item.AlbumId,
artistName: item?.ArtistItems?.[0]?.Name,
artists: item?.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null,
channels: null,
comment: null,
compilation: null,
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000,
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({
container: item.MediaSources?.[0]?.Container,
deviceId,
eTag: item.MediaSources?.[0]?.ETag,
id: item.Id,
mediaSourceId: item.MediaSources?.[0]?.Id,
server,
}),
trackNumber: item.IndexNumber,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: ServerListItem | null,
imageSize?: number,
): Album => {
return {
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
backdropImageUrl: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear || null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
uniqueId: nanoid(),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: ServerListItem | null,
imageSize?: number,
): AlbumArtist => {
const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({
id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item: entry,
size: imageSize || 300,
}),
name: entry.Name,
}),
) || [];
return {
albumCount: null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
similarArtists,
songCount: null,
userFavorite: item.UserData?.IsFavorite || false,
userRating: null,
};
};
const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
id: item.Id,
imagePlaceholderUrl,
imageUrl: imageUrl || null,
itemType: LibraryItem.PLAYLIST,
name: item.Name,
owner: null,
ownerId: null,
public: null,
rules: null,
serverId: server?.id || '',
serverType: ServerType.JELLYFIN,
size: null,
songCount: item?.ChildCount || null,
sync: null,
};
};
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
return {
id: item.Id,
name: item.Name,
};
};
// const normalizeArtist = (item: any) => {
// return {
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
// albumCount: item.AlbumCount,
// duration: item.RunTimeTicks / 10000000,
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
// id: item.Id,
// image: getCoverArtUrl(item),
// info: {
// biography: item.Overview,
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
// imageUrl: undefined,
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
// },
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
// title: item.Name,
// uniqueId: nanoid(),
// };
// };
const normalizeGenre = (item: JFGenre): Genre => {
return {
albumCount: undefined,
id: item.Id,
name: item.Name,
songCount: undefined,
};
};
// const normalizeFolder = (item: any) => {
// return {
// created: item.DateCreated,
// id: item.Id,
// image: getCoverArtUrl(item, 150),
// isDir: true,
// title: item.Name,
// type: Item.Folder,
// uniqueId: nanoid(),
// };
// };
// const normalizeScanStatus = () => {
// return {
// count: 'N/a',
// scanning: false,
// };
// };
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
musicFolder: normalizeMusicFolder,
playlist: normalizePlaylist,
song: normalizeSong,
};
+686
View File
@@ -0,0 +1,686 @@
import { z } from 'zod';
const sortOrderValues = ['Ascending', 'Descending'] as const;
const jfExternal = {
IMDB: 'Imdb',
MUSIC_BRAINZ: 'MusicBrainz',
THE_AUDIO_DB: 'TheAudioDb',
THE_MOVIE_DB: 'TheMovieDb',
TVDB: 'Tvdb',
};
const jfImage = {
BACKDROP: 'Backdrop',
BANNER: 'Banner',
BOX: 'Box',
CHAPTER: 'Chapter',
DISC: 'Disc',
LOGO: 'Logo',
PRIMARY: 'Primary',
THUMB: 'Thumb',
} as const;
const jfCollection = {
MUSIC: 'music',
PLAYLISTS: 'playlists',
} as const;
const error = z.object({
errors: z.object({
recursive: z.array(z.string()),
}),
status: z.number(),
title: z.string(),
traceId: z.string(),
type: z.string(),
});
const baseParameters = z.object({
AlbumArtistIds: z.string().optional(),
ArtistIds: z.string().optional(),
ContributingArtistIds: z.string().optional(),
EnableImageTypes: z.string().optional(),
EnableTotalRecordCount: z.boolean().optional(),
EnableUserData: z.boolean().optional(),
EnableUserDataTypes: z.boolean().optional(),
ExcludeArtistIds: z.string().optional(),
ExcludeItemIds: z.string().optional(),
ExcludeItemTypes: z.string().optional(),
Fields: z.string().optional(),
ImageTypeLimit: z.number().optional(),
IncludeArtists: z.boolean().optional(),
IncludeGenres: z.boolean().optional(),
IncludeItemTypes: z.string().optional(),
IncludeMedia: z.boolean().optional(),
IncludePeople: z.boolean().optional(),
IncludeStudios: z.boolean().optional(),
IsFavorite: z.boolean().optional(),
Limit: z.number().optional(),
MediaTypes: z.string().optional(),
NameStartsWith: z.string().optional(),
ParentId: z.string().optional(),
Recursive: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(),
Tags: z.string().optional(),
UserId: z.string().optional(),
Years: z.string().optional(),
});
const paginationParameters = z.object({
Limit: z.number().optional(),
SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(),
});
const pagination = z.object({
StartIndex: z.number(),
TotalRecordCount: z.number(),
});
const imageTags = z.object({
Logo: z.string().optional(),
Primary: z.string().optional(),
});
const imageBlurHashes = z.object({
Backdrop: z.record(z.string(), z.string()).optional(),
Logo: z.record(z.string(), z.string()).optional(),
Primary: z.record(z.string(), z.string()).optional(),
});
const userData = z.object({
IsFavorite: z.boolean(),
Key: z.string(),
PlayCount: z.number(),
PlaybackPositionTicks: z.number(),
Played: z.boolean(),
});
const externalUrl = z.object({
Name: z.string(),
Url: z.string(),
});
const mediaStream = z.object({
AspectRatio: z.string().optional(),
BitDepth: z.number().optional(),
BitRate: z.number().optional(),
ChannelLayout: z.string().optional(),
Channels: z.number().optional(),
Codec: z.string(),
CodecTimeBase: z.string(),
ColorSpace: z.string().optional(),
Comment: z.string().optional(),
DisplayTitle: z.string().optional(),
Height: z.number().optional(),
Index: z.number(),
IsDefault: z.boolean(),
IsExternal: z.boolean(),
IsForced: z.boolean(),
IsInterlaced: z.boolean(),
IsTextSubtitleStream: z.boolean(),
Level: z.number(),
PixelFormat: z.string().optional(),
Profile: z.string().optional(),
RealFrameRate: z.number().optional(),
RefFrames: z.number().optional(),
SampleRate: z.number().optional(),
SupportsExternalStream: z.boolean(),
TimeBase: z.string(),
Type: z.string(),
Width: z.number().optional(),
});
const mediaSources = z.object({
Bitrate: z.number(),
Container: z.string(),
DefaultAudioStreamIndex: z.number(),
ETag: z.string(),
Formats: z.array(z.any()),
GenPtsInput: z.boolean(),
Id: z.string(),
IgnoreDts: z.boolean(),
IgnoreIndex: z.boolean(),
IsInfiniteStream: z.boolean(),
IsRemote: z.boolean(),
MediaAttachments: z.array(z.any()),
MediaStreams: z.array(mediaStream),
Name: z.string(),
Path: z.string(),
Protocol: z.string(),
ReadAtNativeFramerate: z.boolean(),
RequiredHttpHeaders: z.any(),
RequiresClosing: z.boolean(),
RequiresLooping: z.boolean(),
RequiresOpening: z.boolean(),
RunTimeTicks: z.number(),
Size: z.number(),
SupportsDirectPlay: z.boolean(),
SupportsDirectStream: z.boolean(),
SupportsProbing: z.boolean(),
SupportsTranscoding: z.boolean(),
Type: z.string(),
});
const sessionInfo = z.object({
AdditionalUsers: z.array(z.any()),
ApplicationVersion: z.string(),
Capabilities: z.object({
PlayableMediaTypes: z.array(z.any()),
SupportedCommands: z.array(z.any()),
SupportsContentUploading: z.boolean(),
SupportsMediaControl: z.boolean(),
SupportsPersistentIdentifier: z.boolean(),
SupportsSync: z.boolean(),
}),
Client: z.string(),
DeviceId: z.string(),
DeviceName: z.string(),
HasCustomDeviceName: z.boolean(),
Id: z.string(),
IsActive: z.boolean(),
LastActivityDate: z.string(),
LastPlaybackCheckIn: z.string(),
NowPlayingQueue: z.array(z.any()),
NowPlayingQueueFullItems: z.array(z.any()),
PlayState: z.object({
CanSeek: z.boolean(),
IsMuted: z.boolean(),
IsPaused: z.boolean(),
RepeatMode: z.string(),
}),
PlayableMediaTypes: z.array(z.any()),
RemoteEndPoint: z.string(),
ServerId: z.string(),
SupportedCommands: z.array(z.any()),
SupportsMediaControl: z.boolean(),
SupportsRemoteControl: z.boolean(),
UserId: z.string(),
UserName: z.string(),
});
const configuration = z.object({
DisplayCollectionsView: z.boolean(),
DisplayMissingEpisodes: z.boolean(),
EnableLocalPassword: z.boolean(),
EnableNextEpisodeAutoPlay: z.boolean(),
GroupedFolders: z.array(z.any()),
HidePlayedInLatest: z.boolean(),
LatestItemsExcludes: z.array(z.any()),
MyMediaExcludes: z.array(z.any()),
OrderedViews: z.array(z.any()),
PlayDefaultAudioTrack: z.boolean(),
RememberAudioSelections: z.boolean(),
RememberSubtitleSelections: z.boolean(),
SubtitleLanguagePreference: z.string(),
SubtitleMode: z.string(),
});
const policy = z.object({
AccessSchedules: z.array(z.any()),
AuthenticationProviderId: z.string(),
BlockUnratedItems: z.array(z.any()),
BlockedChannels: z.array(z.any()),
BlockedMediaFolders: z.array(z.any()),
BlockedTags: z.array(z.any()),
EnableAllChannels: z.boolean(),
EnableAllDevices: z.boolean(),
EnableAllFolders: z.boolean(),
EnableAudioPlaybackTranscoding: z.boolean(),
EnableContentDeletion: z.boolean(),
EnableContentDeletionFromFolders: z.array(z.any()),
EnableContentDownloading: z.boolean(),
EnableLiveTvAccess: z.boolean(),
EnableLiveTvManagement: z.boolean(),
EnableMediaConversion: z.boolean(),
EnableMediaPlayback: z.boolean(),
EnablePlaybackRemuxing: z.boolean(),
EnablePublicSharing: z.boolean(),
EnableRemoteAccess: z.boolean(),
EnableRemoteControlOfOtherUsers: z.boolean(),
EnableSharedDeviceControl: z.boolean(),
EnableSyncTranscoding: z.boolean(),
EnableUserPreferenceAccess: z.boolean(),
EnableVideoPlaybackTranscoding: z.boolean(),
EnabledChannels: z.array(z.any()),
EnabledDevices: z.array(z.any()),
EnabledFolders: z.array(z.any()),
ForceRemoteSourceTranscoding: z.boolean(),
InvalidLoginAttemptCount: z.number(),
IsAdministrator: z.boolean(),
IsDisabled: z.boolean(),
IsHidden: z.boolean(),
LoginAttemptsBeforeLockout: z.number(),
MaxActiveSessions: z.number(),
PasswordResetProviderId: z.string(),
RemoteClientBitrateLimit: z.number(),
SyncPlayAccess: z.string(),
});
const user = z.object({
Configuration: configuration,
EnableAutoLogin: z.boolean(),
HasConfiguredEasyPassword: z.boolean(),
HasConfiguredPassword: z.boolean(),
HasPassword: z.boolean(),
Id: z.string(),
LastActivityDate: z.string(),
LastLoginDate: z.string(),
Name: z.string(),
Policy: policy,
ServerId: z.string(),
});
const authenticateParameters = z.object({
Pw: z.string(),
Username: z.string(),
});
const authenticate = z.object({
AccessToken: z.string(),
ServerId: z.string(),
SessionInfo: sessionInfo,
User: user,
});
const genreItem = z.object({
Id: z.string(),
Name: z.string(),
});
const genre = z.object({
BackdropImageTags: z.array(z.any()),
ChannelId: z.null(),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
LocationType: z.string(),
Name: z.string(),
ServerId: z.string(),
Type: z.string(),
});
const genreList = z.object({
Items: z.array(genre),
});
const musicFolder = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
CollectionType: z.string(),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
Name: z.string(),
ServerId: z.string(),
Type: z.string(),
UserData: userData,
});
const musicFolderListParameters = z.object({
UserId: z.string(),
});
const musicFolderList = z.object({
Items: z.array(musicFolder),
});
const playlist = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
ChildCount: z.number().optional(),
DateCreated: z.string(),
GenreItems: z.array(genreItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
MediaType: z.string(),
Name: z.string(),
Overview: z.string().optional(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Type: z.string(),
UserData: userData,
});
const jfPlaylistListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
DURATION: 'Runtime',
NAME: 'SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
SONG_COUNT: 'ChildCount',
} as const;
const playlistListParameters = paginationParameters.merge(
baseParameters.extend({
IncludeItemTypes: z.literal('Playlist'),
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
}),
);
const playlistList = pagination.extend({
Items: z.array(playlist),
});
const genericItem = z.object({
Id: z.string(),
Name: z.string(),
});
const song = z.object({
Album: z.string(),
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumId: z.string(),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genericItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IndexNumber: z.number(),
IsFolder: z.boolean(),
LocationType: z.string(),
MediaSources: z.array(mediaSources),
MediaType: z.string(),
Name: z.string(),
ParentIndexNumber: z.number(),
PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
RunTimeTicks: z.number(),
ServerId: z.string(),
SortName: z.string(),
Type: z.string(),
UserData: userData.optional(),
});
const albumArtist = z.object({
BackdropImageTags: z.array(z.string()),
ChannelId: z.null(),
DateCreated: z.string(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genreItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
LocationType: z.string(),
Name: z.string(),
Overview: z.string(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Type: z.string(),
UserData: userData.optional(),
});
const albumDetailParameters = baseParameters;
const album = z.object({
AlbumArtist: z.string(),
AlbumArtists: z.array(genericItem),
AlbumPrimaryImageTag: z.string(),
ArtistItems: z.array(genericItem),
Artists: z.array(z.string()),
ChannelId: z.null(),
ChildCount: z.number().optional(),
DateCreated: z.string(),
DateLastMediaAdded: z.string().optional(),
ExternalUrls: z.array(externalUrl),
GenreItems: z.array(genericItem),
Genres: z.array(z.string()),
Id: z.string(),
ImageBlurHashes: imageBlurHashes,
ImageTags: imageTags,
IsFolder: z.boolean(),
LocationType: z.string(),
Name: z.string(),
ParentLogoImageTag: z.string(),
ParentLogoItemId: z.string(),
PremiereDate: z.string().optional(),
ProductionYear: z.number(),
RunTimeTicks: z.number(),
ServerId: z.string(),
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
Type: z.string(),
UserData: userData.optional(),
});
const jfAlbumListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
CRITIC_RATING: 'CriticRating,SortName',
NAME: 'SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
} as const;
const albumListParameters = paginationParameters.merge(
baseParameters.extend({
Filters: z.string().optional(),
GenreIds: z.string().optional(),
Genres: z.string().optional(),
IncludeItemTypes: z.literal('MusicAlbum'),
IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
);
const albumList = pagination.extend({
Items: z.array(album),
});
const jfAlbumArtistListSort = {
ALBUM: 'Album,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
} as const;
const albumArtistListParameters = paginationParameters.merge(
baseParameters.extend({
Filters: z.string().optional(),
Genres: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
Years: z.string().optional(),
}),
);
const albumArtistList = pagination.extend({
Items: z.array(albumArtist),
});
const similarArtistListParameters = baseParameters.extend({
Limit: z.number().optional(),
});
const jfSongListSort = {
ALBUM: 'Album,SortName',
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
ARTIST: 'Artist,Album,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName',
PLAY_COUNT: 'PlayCount,SortName',
RANDOM: 'Random,SortName',
RECENTLY_ADDED: 'DateCreated,SortName',
RECENTLY_PLAYED: 'DatePlayed,SortName',
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
} as const;
const songListParameters = paginationParameters.merge(
baseParameters.extend({
AlbumArtistIds: z.string().optional(),
AlbumIds: z.string().optional(),
ArtistIds: z.string().optional(),
Filters: z.string().optional(),
GenreIds: z.string().optional(),
Genres: z.string().optional(),
IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfSongListSort).optional(),
Tags: z.string().optional(),
Years: z.string().optional(),
}),
);
const songList = pagination.extend({
Items: z.array(song),
});
const playlistSongList = songList;
const topSongsList = songList;
const playlistDetailParameters = baseParameters.extend({
Ids: z.string(),
});
const createPlaylistParameters = z.object({
MediaType: z.literal('Audio'),
Name: z.string(),
Overview: z.string(),
UserId: z.string(),
});
const createPlaylist = z.object({
Id: z.string(),
});
const updatePlaylist = z.null();
const updatePlaylistParameters = z.object({
Genres: z.array(genreItem),
MediaType: z.literal('Audio'),
Name: z.string(),
Overview: z.string(),
PremiereDate: z.null(),
ProviderIds: z.object({}),
Tags: z.array(genericItem),
UserId: z.string(),
});
const addToPlaylist = z.object({
Added: z.number(),
});
const addToPlaylistParameters = z.object({
Ids: z.array(z.string()),
UserId: z.string(),
});
const removeFromPlaylist = z.null();
const removeFromPlaylistParameters = z.object({
EntryIds: z.array(z.string()),
});
const deletePlaylist = z.null();
const deletePlaylistParameters = z.object({
Id: z.string(),
});
const scrobbleParameters = z.object({
EventName: z.string().optional(),
IsPaused: z.boolean().optional(),
ItemId: z.string(),
PositionTicks: z.number().optional(),
});
const scrobble = z.any();
const favorite = z.object({
IsFavorite: z.boolean(),
ItemId: z.string(),
Key: z.string(),
LastPlayedDate: z.string(),
Likes: z.boolean(),
PlayCount: z.number(),
PlaybackPositionTicks: z.number(),
Played: z.boolean(),
PlayedPercentage: z.number(),
Rating: z.number(),
UnplayedItemCount: z.number(),
});
const favoriteParameters = z.object({});
const searchParameters = paginationParameters.merge(baseParameters);
const search = z.any();
export const jfType = {
_enum: {
collection: jfCollection,
external: jfExternal,
image: jfImage,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistDetail: baseParameters,
albumArtistList: albumArtistListParameters,
albumDetail: albumDetailParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
favorite,
genre,
genreList,
musicFolderList,
playlist,
playlistList,
playlistSongList,
removeFromPlaylist,
scrobble,
search,
song,
songList,
topSongsList,
updatePlaylist,
user,
},
};
-756
View File
@@ -1,756 +0,0 @@
import { nanoid } from 'nanoid/non-secure';
import ky from 'ky';
import type {
NDGenreListResponse,
NDArtistListResponse,
NDAlbumDetail,
NDAlbumListParams,
NDAlbumList,
NDSongDetailResponse,
NDAlbum,
NDSong,
NDAuthenticationResponse,
NDAlbumDetailResponse,
NDSongDetail,
NDGenreList,
NDAlbumArtistListParams,
NDAlbumArtistDetail,
NDAlbumListResponse,
NDAlbumArtistDetailResponse,
NDAlbumArtistList,
NDSongListParams,
NDCreatePlaylistParams,
NDCreatePlaylistResponse,
NDDeletePlaylist,
NDDeletePlaylistResponse,
NDPlaylistListParams,
NDPlaylistDetail,
NDPlaylistList,
NDPlaylistListResponse,
NDPlaylistDetailResponse,
NDSongList,
NDSongListResponse,
NDAlbumArtist,
NDPlaylist,
NDUpdatePlaylistParams,
NDUpdatePlaylistResponse,
NDPlaylistSongListResponse,
NDPlaylistSongList,
NDPlaylistSong,
NDUserList,
NDUserListResponse,
NDUserListParams,
NDUser,
NDAddToPlaylist,
NDAddToPlaylistBody,
NDAddToPlaylistResponse,
NDRemoveFromPlaylistParams,
NDRemoveFromPlaylistResponse,
NDRemoveFromPlaylist,
} from '/@/renderer/api/navidrome.types';
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
Album,
Song,
AuthenticationResponse,
AlbumDetailArgs,
GenreListArgs,
AlbumListArgs,
AlbumArtistListArgs,
AlbumArtistDetailArgs,
SongListArgs,
SongDetailArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistListArgs,
PlaylistDetailArgs,
CreatePlaylistResponse,
PlaylistSongListArgs,
AlbumArtist,
Playlist,
UpdatePlaylistResponse,
UpdatePlaylistArgs,
UserListArgs,
userListSortMap,
playlistListSortMap,
albumArtistListSortMap,
songListSortMap,
albumListSortMap,
sortOrderMap,
User,
LibraryItem,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const api = ky.create({
hooks: {
afterResponse: [
async (_request, _options, response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: response.headers.get('x-nd-authorization') as string,
});
}
return response;
},
],
beforeError: [
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.setCurrentServer(null);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
}
}
return error;
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const authenticate = async (
url: string,
body: { password: string; username: string },
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const data = await ky
.post(`${cleanServerUrl}/auth/login`, {
json: {
password: body.password,
username: body.username,
},
})
.json<NDAuthenticationResponse>();
return {
credential: `u=${body.username}&s=${data.subsonicSalt}&t=${data.subsonicToken}`,
ndCredential: data.token,
userId: data.id,
username: data.username,
};
};
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
const { query, server, signal } = args;
const searchParams: NDUserListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/user', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDUserListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const { server, signal } = args;
const data = await api
.get('api/genre', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDGenreListResponse>();
return data;
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
const { query, server, signal } = args;
const artistInfo = await subsonicApi.getArtistInfo({
query: {
artistId: query.id,
limit: 15,
},
server,
signal,
});
const data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDAlbumArtistDetailResponse>();
return { ...data, similarArtists: artistInfo.similarArtist };
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
const { query, server, signal } = args;
const searchParams: NDAlbumArtistListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query.ndParams,
};
const res = await api.get('api/artist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDArtistListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query.startIndex,
totalRecordCount: Number(itemCount),
};
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/album/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDAlbumDetailResponse>();
const songsData = await api
.get('api/song', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: {
_end: 0,
_order: NDSortOrder.ASC,
_sort: 'album',
_start: 0,
album_id: query.id,
},
signal,
})
.json<NDSongListResponse>();
return { ...data, songs: songsData };
};
const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
const { query, server, signal } = args;
const searchParams: NDAlbumListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
name: query.searchTerm,
...query.ndParams,
};
const res = await api.get('api/album', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDAlbumListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
const { query, server, signal } = args;
const searchParams: NDSongListParams = {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_id: query.albumIds,
artist_id: query.artistIds,
title: query.searchTerm,
...query.ndParams,
};
const res = await api.get('api/song', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDSongListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/song/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDSongDetailResponse>();
return data;
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { body, server } = args;
const json: NDCreatePlaylistParams = {
comment: body.comment,
name: body.name,
...body.ndParams,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
};
const data = await api
.post('api/playlist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
})
.json<NDCreatePlaylistResponse>();
return {
id: data.id,
name: body.name,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, server, signal } = args;
const json: NDUpdatePlaylistParams = {
comment: body.comment || '',
name: body.name,
ownerId: body.ndParams?.ownerId || undefined,
ownerName: body.ndParams?.owner || undefined,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
sync: body.ndParams?.sync || undefined,
};
const data = await api
.put(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDUpdatePlaylistResponse>();
return {
id: data.id,
};
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<NDDeletePlaylist> => {
const { query, server, signal } = args;
const data = await api
.delete(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDDeletePlaylistResponse>();
return data;
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList> => {
const { query, server, signal } = args;
const searchParams: NDPlaylistListParams = {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/playlist', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDPlaylistListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDetail> => {
const { query, server, signal } = args;
const data = await api
.get(`api/playlist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDPlaylistDetailResponse>();
return data;
};
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlaylistSongList> => {
const { query, server, signal } = args;
const searchParams: NDSongListParams & { playlist_id: string } = {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : NDSongListSort.ID,
_start: query.startIndex,
playlist_id: query.id,
};
const res = await api.get(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDPlaylistSongListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
const { query, body, server, signal } = args;
const json: NDAddToPlaylistBody = {
ids: body.songId,
};
await api
.post(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDAddToPlaylistResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: NDRemoveFromPlaylistParams = {
id: query.songId,
};
await api
.delete(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<NDRemoveFromPlaylistResponse>();
return null;
};
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
credential: string;
size: number;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const normalizeSong = (
item: NDSong | NDPlaylistSong,
server: ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: id,
credential: server.credential,
size: imageSize || 100,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
comment: item.comment ? item.comment : null,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
duration: item.duration,
genres: item.genres,
id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
};
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArtId || item.id,
credential: server.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres,
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): AlbumArtist => {
const imageUrl =
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
serverId: server.id,
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizePlaylist = (
item: NDPlaylist,
server: ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.id,
credential: server.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
description: item.comment,
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
sync: item.sync,
};
};
const normalizeUser = (item: NDUser): User => {
return {
createdAt: item.createdAt,
email: item.email,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
export const navidromeApi = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};
+285
View File
@@ -0,0 +1,285 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { ndType } from './navidrome-types';
import { resultWithHeaders } from '/@/renderer/api/utils';
import { toast } from '/@/renderer/components/toast/index';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
const c = initContract();
export const contract = c.router({
addToPlaylist: {
body: ndType._parameters.addToPlaylist,
method: 'POST',
path: 'playlist/:id/tracks',
responses: {
200: resultWithHeaders(ndType._response.addToPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
authenticate: {
body: ndType._parameters.authenticate,
method: 'POST',
path: 'auth/login',
responses: {
200: resultWithHeaders(ndType._response.authenticate),
500: resultWithHeaders(ndType._response.error),
},
},
createPlaylist: {
body: ndType._parameters.createPlaylist,
method: 'POST',
path: 'playlist',
responses: {
200: resultWithHeaders(ndType._response.createPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'artist/:id',
responses: {
200: resultWithHeaders(ndType._response.albumArtist),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumArtistList: {
method: 'GET',
path: 'artist',
query: ndType._parameters.albumArtistList,
responses: {
200: resultWithHeaders(ndType._response.albumArtistList),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumDetail: {
method: 'GET',
path: 'album/:id',
responses: {
200: resultWithHeaders(ndType._response.album),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumList: {
method: 'GET',
path: 'album',
query: ndType._parameters.albumList,
responses: {
200: resultWithHeaders(ndType._response.albumList),
500: resultWithHeaders(ndType._response.error),
},
},
getGenreList: {
method: 'GET',
path: 'genre',
responses: {
200: resultWithHeaders(ndType._response.genreList),
500: resultWithHeaders(ndType._response.error),
},
},
getPlaylistDetail: {
method: 'GET',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.playlist),
500: resultWithHeaders(ndType._response.error),
},
},
getPlaylistList: {
method: 'GET',
path: 'playlist',
query: ndType._parameters.playlistList,
responses: {
200: resultWithHeaders(ndType._response.playlistList),
500: resultWithHeaders(ndType._response.error),
},
},
getPlaylistSongList: {
method: 'GET',
path: 'playlist/:id/tracks',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.playlistSongList),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
responses: {
200: resultWithHeaders(ndType._response.song),
500: resultWithHeaders(ndType._response.error),
},
},
getSongList: {
method: 'GET',
path: 'song',
query: ndType._parameters.songList,
responses: {
200: resultWithHeaders(ndType._response.songList),
500: resultWithHeaders(ndType._response.error),
},
},
getUserList: {
method: 'GET',
path: 'user',
query: ndType._parameters.userList,
responses: {
200: resultWithHeaders(ndType._response.userList),
500: resultWithHeaders(ndType._response.error),
},
},
removeFromPlaylist: {
body: null,
method: 'DELETE',
path: 'playlist/:id/tracks',
query: ndType._parameters.removeFromPlaylist,
responses: {
200: resultWithHeaders(ndType._response.removeFromPlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
path: 'playlist/:id',
responses: {
200: resultWithHeaders(ndType._response.updatePlaylist),
500: resultWithHeaders(ndType._response.error),
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: response.headers['x-nd-authorization'] as string,
});
}
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
}
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
// Convert indexed object to array
const newParams: Record<string, any> = {};
Object.keys(parsedParams).forEach((key) => {
const isIndexedArrayObject =
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0');
if (!isIndexedArrayObject) {
newParams[key] = parsedParams[key];
} else {
newParams[key] = Object.values(parsedParams[key] || {});
}
});
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
};
export const ndApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
let token: string | undefined;
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server?.url}/api`;
token = server?.ndCredential;
} else {
baseUrl = url;
}
try {
const result = await axiosClient.request({
data: body,
headers: {
...headers,
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
},
method: method as Method,
params,
signal,
url: `${baseUrl}/${api}`,
});
return {
body: { data: result.data, headers: result.headers },
status: result.status,
};
} catch (e: Error | AxiosError | any) {
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
return {
body: { data: response.data, headers: response.headers },
status: response.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
jsonQuery: false,
});
};
@@ -0,0 +1,463 @@
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
AddToPlaylistArgs,
AddToPlaylistResponse,
CreatePlaylistResponse,
CreatePlaylistArgs,
DeletePlaylistArgs,
DeletePlaylistResponse,
AlbumArtistListResponse,
AlbumArtistListArgs,
albumArtistListSortMap,
sortOrderMap,
AuthenticationResponse,
UserListResponse,
UserListArgs,
userListSortMap,
GenreListArgs,
GenreListResponse,
AlbumDetailResponse,
AlbumDetailArgs,
AlbumListArgs,
albumListSortMap,
AlbumListResponse,
SongListResponse,
SongListArgs,
songListSortMap,
SongDetailResponse,
SongDetailArgs,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
PlaylistListResponse,
PlaylistDetailArgs,
PlaylistListArgs,
playlistListSortMap,
PlaylistDetailResponse,
PlaylistSongListArgs,
PlaylistSongListResponse,
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
const authenticate = async (
url: string,
body: { password: string; username: string },
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
body: {
password: body.password,
username: body.username,
},
});
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
return {
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
ndCredential: res.body.data.token,
userId: res.body.data.id,
username: res.body.data.username,
};
};
const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getUserList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get user list');
}
return {
items: res.body.data.map((user) => ndNormalize.user(user)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getGenreList({});
if (res.status !== 200) {
throw new Error('Failed to get genre list');
}
return {
items: res.body.data,
startIndex: 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
): Promise<AlbumArtistDetailResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
params: {
id: query.id,
},
});
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist detail');
}
if (!apiClientProps.server) {
throw new Error('Server is required');
}
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
}),
},
apiClientProps.server,
);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumArtistListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
return {
items: res.body.data.map((albumArtist) =>
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
),
startIndex: query.startIndex,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
const { query, apiClientProps } = args;
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
params: {
id: query.id,
},
});
const songsData = await ndApiClient(apiClientProps).getSongList({
query: {
_end: 0,
_order: 'ASC',
_sort: 'album',
_start: 0,
album_id: [query.id],
},
});
if (albumRes.status !== 200 || songsData.status !== 200) {
throw new Error('Failed to get album detail');
}
return ndNormalize.album(
{ ...albumRes.body.data, songs: songsData.body.data },
apiClientProps.server,
);
};
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
name: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
return {
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
_end: query.startIndex + (query.limit || -1),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
album_artist_id: query.artistIds,
album_id: query.albumIds,
title: query.searchTerm,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
return {
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getSongDetail({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song detail');
}
return ndNormalize.song(res.body.data, apiClientProps.server, '');
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).createPlaylist({
body: {
comment: body.comment,
name: body.name,
public: body._custom?.navidrome?.public,
rules: body._custom?.navidrome?.rules,
sync: body._custom?.navidrome?.sync,
},
});
if (res.status !== 200) {
throw new Error('Failed to create playlist');
}
return {
id: res.body.data.id,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).updatePlaylist({
body: {
comment: body.comment || '',
name: body.name,
public: body._custom?.navidrome?.public || false,
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
sync: body._custom?.navidrome?.sync || undefined,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update playlist');
}
return null;
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).deletePlaylist({
body: null,
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete playlist');
}
return null;
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query._custom?.navidrome,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist list');
}
return {
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist detail');
}
return ndNormalize.playlist(res.body.data, apiClientProps.server);
};
const getPlaylistSongList = async (
args: PlaylistSongListArgs,
): Promise<PlaylistSongListResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
_end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
_start: query.startIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to get playlist song list');
}
return {
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
const { body, query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to add to playlist');
}
return null;
};
const removeFromPlaylist = async (
args: RemoveFromPlaylistArgs,
): Promise<RemoveFromPlaylistResponse> => {
const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
body: null,
params: {
id: query.id,
},
query: {
ids: query.songId,
},
});
if (res.status !== 200) {
throw new Error('Failed to remove from playlist');
}
return null;
};
export const ndController = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};
@@ -0,0 +1,230 @@
import { nanoid } from 'nanoid';
import { Song, LibraryItem, Album, Playlist, User, AlbumArtist } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod';
import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null,
deviceId: string,
imageSize?: number,
): Song => {
let id;
let playlistItemId;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
playlistItemId = item.id;
} else {
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : null,
comment: item.comment ? item.comment : null,
compilation: item.compilation,
container: item.suffix,
createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber,
duration: item.duration,
genres: item.genres,
id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
trackNumber: item.trackNumber,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>;
},
server: ServerListItem | null,
imageSize?: number,
): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null,
genres: item.genres,
id: item.id,
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating || null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl =
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server: ServerListItem | null,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
description: item.comment,
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
sync: item.sync,
};
};
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
return {
createdAt: item.createdAt,
email: item.email || null,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};
@@ -0,0 +1,363 @@
import { z } from 'zod';
const sortOrderValues = ['ASC', 'DESC'] as const;
const error = z.string();
const paginationParameters = z.object({
_end: z.number().optional(),
_order: z.enum(sortOrderValues),
_start: z.number().optional(),
});
const authenticate = z.object({
id: z.string(),
isAdmin: z.boolean(),
name: z.string(),
subsonicSalt: z.string(),
subsonicToken: z.string(),
token: z.string(),
username: z.string(),
});
const authenticateParameters = z.object({
password: z.string(),
username: z.string(),
});
const user = z.object({
createdAt: z.string(),
email: z.string().optional(),
id: z.string(),
isAdmin: z.boolean(),
lastAccessAt: z.string(),
lastLoginAt: z.string(),
name: z.string(),
updatedAt: z.string(),
userName: z.string(),
});
const userList = z.array(user);
const ndUserListSort = {
NAME: 'name',
} as const;
const userListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndUserListSort).optional(),
});
const genre = z.object({
id: z.string(),
name: z.string(),
});
const genreList = z.array(genre);
const albumArtist = z.object({
albumCount: z.number(),
biography: z.string(),
externalInfoUpdatedAt: z.string(),
externalUrl: z.string(),
fullText: z.string(),
genres: z.array(genre),
id: z.string(),
largeImageUrl: z.string().optional(),
mbzArtistId: z.string().optional(),
mediumImageUrl: z.string().optional(),
name: z.string(),
orderArtistName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number(),
size: z.number(),
smallImageUrl: z.string().optional(),
songCount: z.number(),
starred: z.boolean(),
starredAt: z.string(),
});
const albumArtistList = z.array(albumArtist);
const ndAlbumArtistListSort = {
ALBUM_COUNT: 'albumCount',
FAVORITED: 'starred ASC, starredAt ASC',
NAME: 'name',
PLAY_COUNT: 'playCount',
RATING: 'rating',
SONG_COUNT: 'songCount',
} as const;
const albumArtistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
genre_id: z.string().optional(),
name: z.string().optional(),
starred: z.boolean().optional(),
});
const album = z.object({
albumArtist: z.string(),
albumArtistId: z.string(),
allArtistIds: z.string(),
artist: z.string(),
artistId: z.string(),
compilation: z.boolean(),
coverArtId: z.string().optional(), // Removed after v0.48.0
coverArtPath: z.string().optional(), // Removed after v0.48.0
createdAt: z.string(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
id: z.string(),
maxYear: z.number(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
minYear: z.number(),
name: z.string(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
songCount: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
updatedAt: z.string(),
});
const albumList = z.array(album);
const ndAlbumListSort = {
ALBUM_ARTIST: 'albumArtist',
ARTIST: 'artist',
DURATION: 'duration',
NAME: 'name',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'play_date',
RANDOM: 'random',
RATING: 'rating',
RECENTLY_ADDED: 'recently_added',
SONG_COUNT: 'songCount',
STARRED: 'starred',
YEAR: 'max_year',
} as const;
const albumListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndAlbumListSort).optional(),
album_id: z.string().optional(),
artist_id: z.string().optional(),
compilation: z.boolean().optional(),
genre_id: z.string().optional(),
has_rating: z.boolean().optional(),
id: z.string().optional(),
name: z.string().optional(),
recently_added: z.boolean().optional(),
recently_played: z.boolean().optional(),
starred: z.boolean().optional(),
year: z.number().optional(),
});
const song = z.object({
album: z.string(),
albumArtist: z.string(),
albumArtistId: z.string(),
albumId: z.string(),
artist: z.string(),
artistId: z.string(),
bitRate: z.number(),
bookmarkPosition: z.number(),
bpm: z.number().optional(),
channels: z.number().optional(),
comment: z.string().optional(),
compilation: z.boolean(),
createdAt: z.string(),
discNumber: z.number(),
duration: z.number(),
fullText: z.string(),
genre: z.string(),
genres: z.array(genre),
hasCoverArt: z.boolean(),
id: z.string(),
lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(),
orderAlbumArtistName: z.string(),
orderAlbumName: z.string(),
orderArtistName: z.string(),
orderTitle: z.string(),
path: z.string(),
playCount: z.number(),
playDate: z.string(),
rating: z.number().optional(),
size: z.number(),
sortAlbumArtistName: z.string(),
sortArtistName: z.string(),
starred: z.boolean(),
starredAt: z.string().optional(),
suffix: z.string(),
title: z.string(),
trackNumber: z.number(),
updatedAt: z.string(),
year: z.number(),
});
const songList = z.array(song);
const ndSongListSort = {
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS: 'album, discNumber, trackNumber',
ARTIST: 'artist',
BPM: 'bpm',
CHANNELS: 'channels',
COMMENT: 'comment',
DURATION: 'duration',
FAVORITED: 'starred ASC, starredAt ASC',
GENRE: 'genre',
ID: 'id',
PLAY_COUNT: 'playCount',
PLAY_DATE: 'playDate',
RATING: 'rating',
RECENTLY_ADDED: 'createdAt',
TITLE: 'title',
TRACK: 'track',
YEAR: 'year, album, discNumber, trackNumber',
};
const songListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
});
const playlist = z.object({
comment: z.string(),
createdAt: z.string(),
duration: z.number(),
evaluatedAt: z.string(),
id: z.string(),
name: z.string(),
ownerId: z.string(),
ownerName: z.string(),
path: z.string(),
public: z.boolean(),
rules: z.record(z.string(), z.any()),
size: z.number(),
songCount: z.number(),
sync: z.boolean(),
updatedAt: z.string(),
});
const playlistList = z.array(playlist);
const ndPlaylistListSort = {
DURATION: 'duration',
NAME: 'name',
OWNER: 'ownerName',
PUBLIC: 'public',
SONG_COUNT: 'songCount',
UPDATED_AT: 'updatedAt',
} as const;
const playlistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
owner_id: z.string().optional(),
smart: z.boolean().optional(),
});
const playlistSong = song.extend({
mediaFileId: z.string(),
playlistId: z.string(),
});
const playlistSongList = z.array(playlistSong);
const createPlaylist = playlist.pick({
id: true,
});
const createPlaylistParameters = z.object({
comment: z.string().optional(),
name: z.string(),
public: z.boolean().optional(),
rules: z.record(z.any()).optional(),
sync: z.boolean().optional(),
});
const updatePlaylist = playlist;
const updatePlaylistParameters = createPlaylistParameters.partial();
const deletePlaylist = z.null();
const addToPlaylist = z.object({
added: z.number(),
});
const addToPlaylistParameters = z.object({
ids: z.array(z.string()),
});
const removeFromPlaylist = z.object({
ids: z.array(z.string()),
});
const removeFromPlaylistParameters = z.object({
ids: z.array(z.string()),
});
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort,
playlistList: ndPlaylistListSort,
songList: ndSongListSort,
userList: ndUserListSort,
},
_parameters: {
addToPlaylist: addToPlaylistParameters,
albumArtistList: albumArtistListParameters,
albumList: albumListParameters,
authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters,
updatePlaylist: updatePlaylistParameters,
userList: userListParameters,
},
_response: {
addToPlaylist,
album,
albumArtist,
albumArtistList,
albumList,
authenticate,
createPlaylist,
deletePlaylist,
error,
genre,
genreList,
playlist,
playlistList,
playlistSong,
playlistSongList,
removeFromPlaylist,
song,
songList,
updatePlaylist,
user,
userList,
},
};
-292
View File
@@ -1,292 +0,0 @@
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
import type {
JFAlbum,
JFAlbumArtist,
JFGenreList,
JFMusicFolderList,
JFPlaylist,
JFSong,
} from '/@/renderer/api/jellyfin.types';
import { ndNormalize } from '/@/renderer/api/navidrome.api';
import type {
NDAlbum,
NDAlbumArtist,
NDGenreList,
NDPlaylist,
NDSong,
NDUser,
} from '/@/renderer/api/navidrome.types';
import { ssNormalize } from '/@/renderer/api/subsonic.api';
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
import type {
Album,
AlbumArtist,
RawAlbumArtistDetailResponse,
RawAlbumArtistListResponse,
RawAlbumDetailResponse,
RawAlbumListResponse,
RawGenreListResponse,
RawMusicFolderListResponse,
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
RawTopSongListResponse,
RawUserListResponse,
} from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
const albumList = (data: RawAlbumListResponse | undefined, server: ServerListItem | null) => {
let albums;
switch (server?.type) {
case 'jellyfin':
albums = data?.items.map((item) => jfNormalize.album(item as JFAlbum, server));
break;
case 'navidrome':
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
break;
case 'subsonic':
break;
}
return {
items: albums,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
const albumDetail = (
data: RawAlbumDetailResponse | undefined,
server: ServerListItem | null,
): Album | undefined => {
let album: Album | undefined;
switch (server?.type) {
case 'jellyfin':
album = jfNormalize.album(data as JFAlbum, server);
break;
case 'navidrome':
album = ndNormalize.album(data as NDAlbum, server);
break;
case 'subsonic':
break;
}
return album;
};
const songList = (data: RawSongListResponse | undefined, server: ServerListItem | null) => {
let songs;
switch (server?.type) {
case 'jellyfin':
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
break;
case 'navidrome':
songs = data?.items.map((item) => ndNormalize.song(item as NDSong, server, ''));
break;
case 'subsonic':
break;
}
return {
items: songs,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
let songs;
switch (server?.type) {
case 'jellyfin':
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
break;
case 'navidrome':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
case 'subsonic':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
}
return {
items: songs,
};
};
const musicFolderList = (
data: RawMusicFolderListResponse | undefined,
server: ServerListItem | null,
) => {
let musicFolders;
switch (server?.type) {
case 'jellyfin':
musicFolders = (data as JFMusicFolderList)?.map((item) => ({
id: String(item.Id),
name: item.Name,
}));
break;
case 'navidrome':
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
id: String(item.id),
name: item.name,
}));
break;
case 'subsonic':
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
id: String(item.id),
name: item.name,
}));
break;
}
return musicFolders;
};
const genreList = (data: RawGenreListResponse | undefined, server: ServerListItem | null) => {
let genres;
switch (server?.type) {
case 'jellyfin':
genres = (data as JFGenreList)?.Items.map((item) => ({
id: String(item.Id),
name: item.Name,
})).sort((a, b) => a.name.localeCompare(b.name));
break;
case 'navidrome':
genres = (data as NDGenreList)
?.map((item) => ({
id: String(item.id),
name: item.name,
}))
.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'subsonic':
genres = (data as SSGenreList)
?.map((item) => ({
id: item.value,
name: item.value,
}))
.sort((a, b) => a.name.localeCompare(b.name));
break;
}
return genres;
};
const albumArtistDetail = (
data: RawAlbumArtistDetailResponse | undefined,
server: ServerListItem | null,
): AlbumArtist | undefined => {
let albumArtist: AlbumArtist | undefined;
switch (server?.type) {
case 'jellyfin':
albumArtist = jfNormalize.albumArtist(data as JFAlbumArtist, server);
break;
case 'navidrome':
albumArtist = ndNormalize.albumArtist(data as NDAlbumArtist, server);
break;
case 'subsonic':
break;
}
return albumArtist;
};
const albumArtistList = (
data: RawAlbumArtistListResponse | undefined,
server: ServerListItem | null,
) => {
let albumArtists;
switch (server?.type) {
case 'jellyfin':
albumArtists = data?.items.map((item) =>
jfNormalize.albumArtist(item as JFAlbumArtist, server),
);
break;
case 'navidrome':
albumArtists = data?.items.map((item) =>
ndNormalize.albumArtist(item as NDAlbumArtist, server),
);
break;
case 'subsonic':
break;
}
return {
items: albumArtists,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
let playlists;
switch (server?.type) {
case 'jellyfin':
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist, server));
break;
case 'navidrome':
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist, server));
break;
case 'subsonic':
break;
}
return {
items: playlists,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
const playlistDetail = (
data: RawPlaylistDetailResponse | undefined,
server: ServerListItem | null,
) => {
let playlist;
switch (server?.type) {
case 'jellyfin':
playlist = jfNormalize.playlist(data as JFPlaylist, server);
break;
case 'navidrome':
playlist = ndNormalize.playlist(data as NDPlaylist, server);
break;
case 'subsonic':
break;
}
return playlist;
};
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
let users;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
break;
case 'subsonic':
break;
}
return {
items: users,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
export const normalize = {
albumArtistDetail,
albumArtistList,
albumDetail,
albumList,
genreList,
musicFolderList,
playlistDetail,
playlistList,
songList,
topSongList,
userList,
};
+25 -3
View File
@@ -1,3 +1,4 @@
import { QueryFunctionContext } from '@tanstack/react-query';
import type {
AlbumListQuery,
SongListQuery,
@@ -10,9 +11,15 @@ import type {
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
SearchQuery,
SongDetailQuery,
RandomSongListQuery,
} from './types';
export const queryKeys = {
export const queryKeys: Record<
string,
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = {
albumArtists: {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
@@ -70,20 +77,35 @@ export const queryKeys = {
return [serverId, 'playlists', 'list'] as const;
},
root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'songList', query] as const;
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const;
},
},
search: {
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
},
root: (serverId: string) => [serverId, 'search'] as const,
},
server: {
root: (serverId: string) => [serverId] as const,
},
songs: {
detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const;
},
list: (serverId: string, query?: SongListQuery) => {
if (query) return [serverId, 'songs', 'list', query] as const;
return [serverId, 'songs', 'list'] as const;
},
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const,
},
users: {
-497
View File
@@ -1,497 +0,0 @@
import ky from 'ky';
import md5 from 'md5';
import { parseSearchParams, randomString } from '/@/renderer/utils';
import type {
SSAlbumListResponse,
SSAlbumDetailResponse,
SSArtistIndex,
SSAlbumArtistList,
SSAlbumArtistListResponse,
SSGenreListResponse,
SSMusicFolderList,
SSMusicFolderListResponse,
SSGenreList,
SSAlbumDetail,
SSAlbumList,
SSAlbumArtistDetail,
SSAlbumArtistDetailResponse,
SSFavoriteParams,
SSRatingParams,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
SSTopSongListParams,
SSTopSongListResponse,
SSArtistInfoParams,
SSArtistInfoResponse,
SSArtistInfo,
SSSong,
SSTopSongList,
SSScrobbleParams,
} from '/@/renderer/api/subsonic.types';
import {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
LibraryItem,
MusicFolderListArgs,
QueueSong,
RatingArgs,
RatingResponse,
RawScrobbleResponse,
ScrobbleArgs,
ServerListItem,
ServerType,
TopSongListArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { nanoid } from 'nanoid/non-secure';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
credential: string;
size: number;
}) => {
const size = args.size ? args.size : 150;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const api = ky.create({
hooks: {
afterResponse: [
async (_request, _options, response) => {
const data = await response.json();
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const getDefaultParams = (server: ServerListItem | null) => {
if (!server) return {};
const authParams = server.credential.split(/&?\w=/gm);
const params: Record<string, string> = {
c: 'Feishin',
f: 'json',
u: server.username,
v: '1.13.0',
};
if (authParams?.length === 4) {
params.s = authParams[2];
params.t = authParams[3];
} else if (authParams?.length === 3) {
params.p = authParams[2];
}
return params;
};
const authenticate = async (
url: string,
body: {
legacy?: boolean;
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
let credential;
const cleanServerUrl = url.replace(/\/$/, '');
if (body.legacy) {
credential = `u=${body.username}&p=${body.password}`;
} else {
const salt = randomString(12);
const hash = md5(body.password + salt);
credential = `u=${body.username}&s=${salt}&t=${hash}`;
}
await ky.get(`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${credential}`);
return {
credential,
userId: null,
username: body.username,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
const { signal, server } = args;
const defaultParams = getDefaultParams(server);
const data = await api
.get('rest/getMusicFolders.view', {
prefixUrl: server?.url,
searchParams: defaultParams,
signal,
})
.json<SSMusicFolderListResponse>();
return data.musicFolders.musicFolder;
};
export const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
): Promise<SSAlbumArtistDetail> => {
const { server, signal, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSAlbumArtistDetailParams = {
id: query.id,
...defaultParams,
};
const data = await api
.get('/getArtist.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSAlbumArtistDetailResponse>();
return data.artist;
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSAlbumArtistListParams = {
musicFolderId: query.musicFolderId,
...defaultParams,
};
const data = await api
.get('rest/getArtists.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSAlbumArtistListResponse>();
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
return {
items: artists,
startIndex: query.startIndex,
totalRecordCount: null,
};
};
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
const { server, signal } = args;
const defaultParams = getDefaultParams(server);
const data = await api
.get('rest/getGenres.view', {
prefixUrl: server?.url,
searchParams: defaultParams,
signal,
})
.json<SSGenreListResponse>();
return data.genres.genre;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const searchParams = {
id: query.id,
...defaultParams,
};
const data = await api
.get('rest/getAlbum.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSAlbumDetailResponse>();
const { song: songs, ...dataWithoutSong } = data.album;
return { ...dataWithoutSong, songs };
};
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const searchParams = {
...defaultParams,
};
const data = await api
.get('rest/getAlbumList2.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSAlbumListResponse>();
return {
items: data.albumList2.album,
startIndex: query.startIndex,
totalRecordCount: null,
};
};
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
for (const id of query.id) {
const searchParams: SSFavoriteParams = {
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
id: query.type === LibraryItem.SONG ? id : undefined,
...defaultParams,
};
await api.get('rest/star.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
// .json<SSFavoriteResponse>();
}
return {
id: query.id,
type: query.type,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
for (const id of query.id) {
const searchParams: SSFavoriteParams = {
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
id: query.type === LibraryItem.SONG ? id : undefined,
...defaultParams,
};
await api.get('rest/unstar.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
// .json<SSFavoriteResponse>();
}
return {
id: query.id,
type: query.type,
};
};
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const itemIds = query.item.map((item) => item.id);
for (const id of itemIds) {
const searchParams: SSRatingParams = {
id,
rating: query.rating,
...defaultParams,
};
await api.get('rest/setRating.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
}
return null;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSTopSongListParams = {
artist: query.artist,
count: query.limit,
...defaultParams,
};
const data = await api
.get('rest/getTopSongs.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSTopSongListResponse>();
return {
items: data?.topSongs?.song,
startIndex: 0,
totalRecordCount: data?.topSongs?.song?.length || 0,
};
};
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSArtistInfoParams = {
count: query.limit,
id: query.artistId,
...defaultParams,
};
const data = await api
.get('rest/getArtistInfo2.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSArtistInfoResponse>();
return data.artistInfo2;
};
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSScrobbleParams = {
id: query.id,
submission: query.submission,
...defaultParams,
};
await api.get('rest/scrobble.view', {
prefixUrl: server?.url,
searchParams,
signal,
});
return null;
};
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArt,
credential: server.credential,
size: 300,
}) || null;
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
return {
album: item.album,
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
albumId: item.albumId,
artistName: item.artist,
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
bitRate: item.bitRate,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration,
genres: [
{
id: item.genre,
name: item.genre,
},
],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server.id,
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const subsonicApi = {
authenticate,
createFavorite,
deleteFavorite,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistInfo,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
getTopSongList,
scrobble,
updateRating,
};
export const ssNormalize = {
song: normalizeSong,
};
+205
View File
@@ -0,0 +1,205 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index';
const c = initContract();
export const contract = c.router({
authenticate: {
method: 'GET',
path: 'ping.view',
query: ssType._parameters.authenticate,
responses: {
200: ssType._response.authenticate,
},
},
createFavorite: {
method: 'GET',
path: 'star.view',
query: ssType._parameters.createFavorite,
responses: {
200: ssType._response.createFavorite,
},
},
getArtistInfo: {
method: 'GET',
path: 'getArtistInfo.view',
query: ssType._parameters.artistInfo,
responses: {
200: ssType._response.artistInfo,
},
},
getMusicFolderList: {
method: 'GET',
path: 'getMusicFolders.view',
responses: {
200: ssType._response.musicFolderList,
},
},
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
query: ssType._parameters.randomSongList,
responses: {
200: ssType._response.randomSongList,
},
},
getTopSongsList: {
method: 'GET',
path: 'getTopSongs.view',
query: ssType._parameters.topSongsList,
responses: {
200: ssType._response.topSongsList,
},
},
removeFavorite: {
method: 'GET',
path: 'unstar.view',
query: ssType._parameters.removeFavorite,
responses: {
200: ssType._response.removeFavorite,
},
},
scrobble: {
method: 'GET',
path: 'scrobble.view',
query: ssType._parameters.scrobble,
responses: {
200: ssType._response.scrobble,
},
},
search3: {
method: 'GET',
path: 'search3.view',
query: ssType._parameters.search3,
responses: {
200: ssType._response.search3,
},
},
setRating: {
method: 'GET',
path: 'setRating.view',
query: ssType._parameters.setRating,
responses: {
200: ssType._response.setRating,
},
},
});
const axiosClient = axios.create({});
axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
const data = response.data;
if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
return response;
},
(error) => {
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
};
export const ssApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
const { params, path: api } = parsePath(path);
if (server) {
baseUrl = `${server.url}/rest`;
const token = server.credential;
const params = token.split(/&?\w=/gm);
authParams.u = server.username;
if (params?.length === 4) {
authParams.s = params[2];
authParams.t = params[3];
} else if (params?.length === 3) {
authParams.p = params[2];
}
} else {
baseUrl = url;
}
try {
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
data: body,
headers,
method: method as Method,
params: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
},
signal,
url: `${baseUrl}/${api}`,
});
return {
body: result.data['subsonic-response'],
status: result.status,
};
} catch (e: Error | AxiosError | any) {
console.log('CATCH ERR');
if (isAxiosError(e)) {
const error = e as AxiosError;
const response = error.response as AxiosResponse;
console.log(response, 'response');
return {
body: response?.data,
status: response?.status,
};
}
throw e;
}
},
baseHeaders: {
'Content-Type': 'application/json',
},
baseUrl: '',
});
};
@@ -0,0 +1,383 @@
import md5 from 'md5';
import { z } from 'zod';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
LibraryItem,
MusicFolderListArgs,
MusicFolderListResponse,
SetRatingArgs,
RatingResponse,
ScrobbleArgs,
ScrobbleResponse,
SongListResponse,
TopSongListArgs,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
const authenticate = async (
url: string,
body: {
legacy?: boolean;
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
let credential: string;
let credentialParams: {
p?: string;
s?: string;
t?: string;
u: string;
};
const cleanServerUrl = url.replace(/\/$/, '');
if (body.legacy) {
credential = `u=${body.username}&p=${body.password}`;
credentialParams = {
p: body.password,
u: body.username,
};
} else {
const salt = randomString(12);
const hash = md5(body.password + salt);
credential = `u=${body.username}&s=${salt}&t=${hash}`;
credentialParams = {
s: salt,
t: hash,
u: body.username,
};
}
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
query: {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...credentialParams,
},
});
return {
credential,
userId: null,
username: body.username,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
const { apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
if (res.status !== 200) {
throw new Error('Failed to get music folder list');
}
return {
items: res.body.musicFolders.musicFolder,
startIndex: 0,
totalRecordCount: res.body.musicFolders.musicFolder.length,
};
};
// export const getAlbumArtistDetail = async (
// args: AlbumArtistDetailArgs,
// ): Promise<SSAlbumArtistDetail> => {
// const { server, signal, query } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams: SSAlbumArtistDetailParams = {
// id: query.id,
// ...defaultParams,
// };
// const data = await api
// .get('/getArtist.view', {
// prefixUrl: server?.url,
// searchParams,
// signal,
// })
// .json<SSAlbumArtistDetailResponse>();
// return data.artist;
// };
// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
// const { signal, server, query } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams: SSAlbumArtistListParams = {
// musicFolderId: query.musicFolderId,
// ...defaultParams,
// };
// const data = await api
// .get('rest/getArtists.view', {
// prefixUrl: server?.url,
// searchParams,
// signal,
// })
// .json<SSAlbumArtistListResponse>();
// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
// return {
// items: artists,
// startIndex: query.startIndex,
// totalRecordCount: null,
// };
// };
// const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
// const { server, signal } = args;
// const defaultParams = getDefaultParams(server);
// const data = await api
// .get('rest/getGenres.view', {
// prefixUrl: server?.url,
// searchParams: defaultParams,
// signal,
// })
// .json<SSGenreListResponse>();
// return data.genres.genre;
// };
// const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
// const { server, query, signal } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams = {
// id: query.id,
// ...defaultParams,
// };
// const data = await api
// .get('rest/getAlbum.view', {
// prefixUrl: server?.url,
// searchParams: parseSearchParams(searchParams),
// signal,
// })
// .json<SSAlbumDetailResponse>();
// const { song: songs, ...dataWithoutSong } = data.album;
// return { ...dataWithoutSong, songs };
// };
// const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
// const { server, query, signal } = args;
// const defaultParams = getDefaultParams(server);
// const searchParams = {
// ...defaultParams,
// };
// const data = await api
// .get('rest/getAlbumList2.view', {
// prefixUrl: server?.url,
// searchParams: parseSearchParams(searchParams),
// signal,
// })
// .json<SSAlbumListResponse>();
// return {
// items: data.albumList2.album,
// startIndex: query.startIndex,
// totalRecordCount: null,
// };
// };
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).createFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to create favorite');
}
return null;
};
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).removeFavorite({
query: {
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
id: query.type === LibraryItem.SONG ? query.id : undefined,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete favorite');
}
return null;
};
const setRating = async (args: SetRatingArgs): Promise<RatingResponse> => {
const { query, apiClientProps } = args;
const itemIds = query.item.map((item) => item.id);
for (const id of itemIds) {
await ssApiClient(apiClientProps).setRating({
query: {
id,
rating: query.rating,
},
});
}
return null;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
artist: query.artist,
count: query.limit,
},
});
if (res.status !== 200) {
throw new Error('Failed to get top songs');
}
return {
items:
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
[],
startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0,
};
};
const getArtistInfo = async (
args: ArtistInfoArgs,
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: query.limit,
id: query.artistId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get artist info');
}
return res.body;
};
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
return null;
};
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: query.albumLimit,
albumOffset: query.albumStartIndex,
artistCount: query.albumArtistLimit,
artistOffset: query.albumArtistStartIndex,
query: query.query,
songCount: query.songLimit,
songOffset: query.songStartIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to search');
}
return {
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server),
),
albums: res.body.searchResult3?.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server),
),
songs: res.body.searchResult3?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
};
};
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getRandomSongList({
query: {
fromYear: query.minYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
size: query.limit,
toYear: query.maxYear,
},
});
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
console.log('res', res);
return {
items: res.body.randomSongs?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
startIndex: 0,
totalRecordCount: res.body.randomSongs?.song?.length || 0,
};
};
export const ssController = {
authenticate,
createFavorite,
getArtistInfo,
getMusicFolderList,
getRandomSongList,
getTopSongList,
removeFavorite,
scrobble,
search3,
setRating,
};
@@ -0,0 +1,179 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId?: string;
credential: string | undefined;
size: number;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=feishin' +
`&size=${size}`
);
};
const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null,
deviceId: string,
): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
return {
album: item.album || '',
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
albumId: item.albumId || '',
artistName: item.artist || '',
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist || '',
},
],
bitRate: item.bitRate || 0,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration || 0,
genres: item.genre
? [
{
id: item.genre,
name: item.genre,
},
]
: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track || 1,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>,
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
return {
albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null,
biography: null,
duration: null,
genres: [],
id: item.id,
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [],
songCount: null,
userFavorite: false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>,
server: ServerListItem | null,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}) || null;
return {
albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs: [],
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong,
};
+240
View File
@@ -0,0 +1,240 @@
import { z } from 'zod';
const baseResponse = z.object({
'subsonic-response': z.object({
status: z.string(),
version: z.string(),
}),
});
const authenticate = z.null();
const authenticateParameters = z.object({
c: z.string(),
f: z.string(),
p: z.string().optional(),
s: z.string().optional(),
t: z.string().optional(),
u: z.string(),
v: z.string(),
});
const createFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const createFavorite = z.null();
const removeFavoriteParameters = z.object({
albumId: z.array(z.string()).optional(),
artistId: z.array(z.string()).optional(),
id: z.array(z.string()).optional(),
});
const removeFavorite = z.null();
const setRatingParameters = z.object({
id: z.string(),
rating: z.number(),
});
const setRating = z.null();
const musicFolder = z.object({
id: z.string(),
name: z.string(),
});
const musicFolderList = z.object({
musicFolders: z.object({
musicFolder: z.array(musicFolder),
}),
});
const song = z.object({
album: z.string().optional(),
albumId: z.string().optional(),
artist: z.string().optional(),
artistId: z.string().optional(),
averageRating: z.number().optional(),
bitRate: z.number().optional(),
contentType: z.string(),
coverArt: z.string().optional(),
created: z.string(),
discNumber: z.number(),
duration: z.number().optional(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
parent: z.string(),
path: z.string(),
playCount: z.number().optional(),
size: z.number(),
starred: z.boolean().optional(),
suffix: z.string(),
title: z.string(),
track: z.number().optional(),
type: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const album = z.object({
album: z.string(),
artist: z.string(),
artistId: z.string(),
coverArt: z.string(),
created: z.string(),
duration: z.number(),
genre: z.string().optional(),
id: z.string(),
isDir: z.boolean(),
isVideo: z.boolean(),
name: z.string(),
parent: z.string(),
song: z.array(song),
songCount: z.number(),
starred: z.boolean().optional(),
title: z.string(),
userRating: z.number().optional(),
year: z.number().optional(),
});
const albumListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
offset: z.number().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
type: z.string().optional(),
});
const albumList = z.array(album.omit({ song: true }));
const albumArtist = z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
});
const albumArtistList = z.object({
artist: z.array(albumArtist),
name: z.string(),
});
const artistInfoParameters = z.object({
count: z.number().optional(),
id: z.string(),
includeNotPresent: z.boolean().optional(),
});
const artistInfo = z.object({
artistInfo: z.object({
biography: z.string().optional(),
largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(),
mediumImageUrl: z.string().optional(),
musicBrainzId: z.string().optional(),
similarArtist: z.array(
z.object({
albumCount: z.string(),
artistImageUrl: z.string().optional(),
coverArt: z.string().optional(),
id: z.string(),
name: z.string(),
}),
),
smallImageUrl: z.string().optional(),
}),
});
const topSongsListParameters = z.object({
artist: z.string(), // The name of the artist, not the artist ID
count: z.number().optional(),
});
const topSongsList = z.object({
topSongs: z.object({
song: z.array(song),
}),
});
const scrobbleParameters = z.object({
id: z.string(),
submission: z.boolean().optional(),
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
});
const scrobble = z.null();
const search3 = z.object({
searchResult3: z.object({
album: z.array(album),
artist: z.array(albumArtist),
song: z.array(song),
}),
});
const search3Parameters = z.object({
albumCount: z.number().optional(),
albumOffset: z.number().optional(),
artistCount: z.number().optional(),
artistOffset: z.number().optional(),
musicFolderId: z.string().optional(),
query: z.string().optional(),
songCount: z.number().optional(),
songOffset: z.number().optional(),
});
const randomSongListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
});
const randomSongList = z.object({
randomSongs: z.object({
song: z.array(song),
}),
});
export const ssType = {
_parameters: {
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
topSongsList: topSongsListParameters,
},
_response: {
album,
albumArtist,
albumArtistList,
albumList,
artistInfo,
authenticate,
baseResponse,
createFavorite,
musicFolderList,
randomSongList,
removeFavorite,
scrobble,
search3,
setRating,
song,
topSongsList,
},
};
+146 -171
View File
@@ -1,50 +1,23 @@
import { z } from 'zod';
import {
JFSortOrder,
JFGenreList,
JFAlbumList,
JFAlbumListSort,
JFAlbumDetail,
JFSongList,
JFSongListSort,
JFAlbumArtistList,
JFAlbumArtistListSort,
JFAlbumArtistDetail,
JFArtistList,
JFArtistListSort,
JFPlaylistList,
JFPlaylistDetail,
JFMusicFolderList,
JFPlaylistListSort,
} from '/@/renderer/api/jellyfin.types';
import {
NDSortOrder,
NDOrder,
NDGenreList,
NDAlbumList,
NDAlbumListSort,
NDAlbumDetail,
NDSongList,
NDSongDetail,
NDAlbumArtistList,
NDAlbumArtistListSort,
NDAlbumArtistDetail,
NDDeletePlaylist,
NDPlaylistList,
NDPlaylistListSort,
NDPlaylistDetail,
NDSongListSort,
NDUserList,
NDUserListSort,
} from '/@/renderer/api/navidrome.types';
import {
SSAlbumList,
SSAlbumDetail,
SSAlbumArtistList,
SSAlbumArtistDetail,
SSMusicFolderList,
SSGenreList,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
export enum LibraryItem {
ALBUM = 'album',
@@ -159,8 +132,10 @@ export type AuthenticationResponse = {
};
export type Genre = {
albumCount?: number;
id: string;
name: string;
songCount?: number;
};
export type Album = {
@@ -192,7 +167,7 @@ export type Album = {
} & { songs?: Song[] };
export type Song = {
album: string;
album: string | null;
albumArtists: RelatedArtist[];
albumId: string;
artistName: string;
@@ -305,14 +280,13 @@ export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = {
_serverId?: string;
server: ServerListItem | null;
signal?: AbortSignal;
apiClientProps: {
server: ServerListItem | null;
signal?: AbortSignal;
};
};
// Genre List
export type RawGenreListResponse = NDGenreList | JFGenreList | SSGenreList | undefined;
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
@@ -320,8 +294,6 @@ export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export type GenreListQuery = null;
// Album List
export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined;
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
export enum AlbumListSort {
@@ -343,31 +315,16 @@ export enum AlbumListSort {
}
export type AlbumListQuery = {
artistIds?: string[];
jfParams?: {
albumArtistIds?: string;
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
isFavorite?: boolean;
maxYear?: number; // Parses to years
minYear?: number; // Parses to years
tags?: string;
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
maxYear?: number;
minYear?: number;
};
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
};
artistIds?: string[];
limit?: number;
musicFolderId?: string;
ndParams?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
};
searchTerm?: string;
sortBy: AlbumListSort;
sortOrder: SortOrder;
@@ -437,8 +394,6 @@ export const albumListSortMap: AlbumListSortMap = {
};
// Album Detail
export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined;
export type AlbumDetailResponse = Album | null | undefined;
export type AlbumDetailQuery = { id: string };
@@ -446,8 +401,6 @@ export type AlbumDetailQuery = { id: string };
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
// Song List
export type RawSongListResponse = NDSongList | JFSongList | undefined;
export type SongListResponse = BasePaginatedResponse<Song[]>;
export enum SongListSort {
@@ -472,33 +425,17 @@ export enum SongListSort {
}
export type SongListQuery = {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
maxYear?: number;
minYear?: number;
};
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
};
albumIds?: string[];
artistIds?: string[];
jfParams?: {
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
includeItemTypes: 'Audio';
isFavorite?: boolean;
maxYear?: number; // Parses to years
minYear?: number; // Parses to years
sortBy?: JFSongListSort;
years?: string;
};
limit?: number;
musicFolderId?: string;
ndParams?: {
album_id?: string[];
artist_id?: string[];
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
starred?: boolean;
title?: string;
year?: number;
};
searchTerm?: string;
sortBy: SongListSort;
sortOrder: SortOrder;
@@ -577,8 +514,6 @@ export const songListSortMap: SongListSortMap = {
};
// Song Detail
export type RawSongDetailResponse = NDSongDetail | undefined;
export type SongDetailResponse = Song | null | undefined;
export type SongDetailQuery = { id: string };
@@ -586,13 +521,7 @@ export type SongDetailQuery = { id: string };
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
// Album Artist List
export type RawAlbumArtistListResponse =
| NDAlbumArtistList
| SSAlbumArtistList
| JFAlbumArtistList
| undefined;
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
export enum AlbumArtistListSort {
ALBUM = 'album',
@@ -609,13 +538,12 @@ export enum AlbumArtistListSort {
}
export type AlbumArtistListQuery = {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
};
limit?: number;
musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
searchTerm?: string;
sortBy: AlbumArtistListSort;
sortOrder: SortOrder;
@@ -673,21 +601,14 @@ export const albumArtistListSortMap: AlbumArtistListSortMap = {
};
// Album Artist Detail
export type RawAlbumArtistDetailResponse =
| NDAlbumArtistDetail
| SSAlbumArtistDetail
| JFAlbumArtistDetail
| undefined;
export type AlbumArtistDetailResponse = BasePaginatedResponse<AlbumArtist[]>;
export type AlbumArtistDetailResponse = AlbumArtist | null;
export type AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
// Artist List
export type RawArtistListResponse = JFArtistList | undefined;
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
export enum ArtistListSort {
@@ -705,13 +626,12 @@ export enum ArtistListSort {
}
export type ArtistListQuery = {
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
};
limit?: number;
musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
sortBy: ArtistListSort;
sortOrder: SortOrder;
startIndex: number;
@@ -770,31 +690,27 @@ export const artistListSortMap: ArtistListSortMap = {
// Artist Detail
// Favorite
export type RawFavoriteResponse = FavoriteResponse | undefined;
export type FavoriteResponse = { id: string[]; type: LibraryItem };
export type FavoriteResponse = null | undefined;
export type FavoriteQuery = {
id: string[];
type: LibraryItem;
};
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
export type FavoriteArgs = { query: FavoriteQuery; serverId?: string } & BaseEndpointArgs;
// Rating
export type RawRatingResponse = RatingResponse | undefined;
export type RatingResponse = null;
export type RatingResponse = null | undefined;
export type RatingQuery = {
item: AnyLibraryItems;
rating: number;
};
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
// Add to playlist
export type RawAddToPlaylistResponse = null | undefined;
export type AddToPlaylistResponse = null | undefined;
export type AddToPlaylistQuery = {
id: string;
@@ -807,76 +723,80 @@ export type AddToPlaylistBody = {
export type AddToPlaylistArgs = {
body: AddToPlaylistBody;
query: AddToPlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Remove from playlist
export type RawRemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistQuery = {
id: string;
songId: string[];
};
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
export type RemoveFromPlaylistArgs = {
query: RemoveFromPlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
export type CreatePlaylistResponse = { id: string; name: string };
export type CreatePlaylistResponse = { id: string } | undefined;
export type CreatePlaylistBody = {
_custom?: {
navidrome?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
comment?: string;
name: string;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type CreatePlaylistArgs = { body: CreatePlaylistBody } & BaseEndpointArgs;
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
// Update Playlist
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
export type UpdatePlaylistResponse = { id: string };
export type UpdatePlaylistResponse = null | undefined;
export type UpdatePlaylistQuery = {
id: string;
};
export type UpdatePlaylistBody = {
_custom?: {
navidrome?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
comment?: string;
genres?: Genre[];
name: string;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type UpdatePlaylistArgs = {
body: UpdatePlaylistBody;
query: UpdatePlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Delete Playlist
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
export type DeletePlaylistResponse = null;
export type DeletePlaylistResponse = null | undefined;
export type DeletePlaylistQuery = { id: string };
export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs;
export type DeletePlaylistArgs = {
query: DeletePlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Playlist List
export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined;
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export enum PlaylistListSort {
@@ -889,11 +809,11 @@ export enum PlaylistListSort {
}
export type PlaylistListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
smart?: boolean;
_custom?: {
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
};
limit?: number;
searchTerm?: string;
sortBy: PlaylistListSort;
sortOrder: SortOrder;
@@ -936,9 +856,7 @@ export const playlistListSortMap: PlaylistListSortMap = {
};
// Playlist Detail
export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined;
export type PlaylistDetailResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistDetailResponse = Playlist;
export type PlaylistDetailQuery = {
id: string;
@@ -947,8 +865,6 @@ export type PlaylistDetailQuery = {
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
// Playlist Songs
export type RawPlaylistSongListResponse = JFSongList | undefined;
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
export type PlaylistSongListQuery = {
@@ -962,16 +878,14 @@ export type PlaylistSongListQuery = {
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
// Music Folder List
export type RawMusicFolderListResponse = SSMusicFolderList | JFMusicFolderList | undefined;
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>;
export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
export type MusicFolderListQuery = null;
export type MusicFolderListArgs = BaseEndpointArgs;
// User list
// Playlist List
export type RawUserListResponse = NDUserList | undefined;
export type UserListResponse = BasePaginatedResponse<User[]>;
export enum UserListSort {
@@ -979,10 +893,12 @@ export enum UserListSort {
}
export type UserListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
_custom?: {
navidrome?: {
owner_id?: string;
};
};
limit?: number;
searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
@@ -1010,8 +926,6 @@ export const userListSortMap: UserListSortMap = {
};
// Top Songs List
export type RawTopSongListResponse = SSTopSongList | JFSongList | undefined;
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
export type TopSongListQuery = {
@@ -1032,10 +946,11 @@ export type ArtistInfoQuery = {
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
// Scrobble
export type RawScrobbleResponse = null | undefined;
export type ScrobbleResponse = null | undefined;
export type ScrobbleArgs = {
query: ScrobbleQuery;
serverId?: string;
} & BaseEndpointArgs;
export type ScrobbleQuery = {
@@ -1044,3 +959,63 @@ export type ScrobbleQuery = {
position?: number;
submission: boolean;
};
export type SearchQuery = {
albumArtistLimit?: number;
albumArtistStartIndex?: number;
albumLimit?: number;
albumStartIndex?: number;
musicFolderId?: string;
query?: string;
songLimit?: number;
songStartIndex?: number;
};
export type SearchSongsQuery = {
musicFolderId?: string;
query?: string;
songLimit?: number;
songStartIndex?: number;
};
export type SearchAlbumsQuery = {
albumLimit?: number;
albumStartIndex?: number;
musicFolderId?: string;
query?: string;
};
export type SearchAlbumArtistsQuery = {
albumArtistLimit?: number;
albumArtistStartIndex?: number;
musicFolderId?: string;
query?: string;
};
export type SearchArgs = {
query: SearchQuery;
} & BaseEndpointArgs;
export type SearchResponse = {
albumArtists: AlbumArtist[];
albums: Album[];
songs: Song[];
};
export type RandomSongListQuery = {
genre?: string;
limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string;
};
export type RandomSongListArgs = {
query: RandomSongListQuery;
} & BaseEndpointArgs;
export type RandomSongListResponse = SongListResponse;
export const instanceOfCancellationError = (error: any) => {
return 'revert' in error;
};
+23
View File
@@ -0,0 +1,23 @@
import { AxiosHeaders } from 'axios';
import { z } from 'zod';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
return z.object({
data: itemSchema,
headers: z.instanceof(AxiosHeaders),
});
};
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
itemSchema: ItemType,
) => {
return z.object({
'subsonic-response': z
.object({
status: z.string(),
version: z.string(),
})
.extend(itemSchema),
});
};
+61 -13
View File
@@ -8,7 +8,7 @@ import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal } from './components';
import { useTheme } from './hooks';
import { AppRouter } from './router/app-router';
import { useSettingsStore } from './store/settings.store';
import { useHotkeySettings, usePlaybackSettings, useSettingsStore } from './store/settings.store';
import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
@@ -17,19 +17,24 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import isElectron from 'is-electron';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { usePlayerStore } from '/@/renderer/store';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
const ipc = isElectron() ? window.electron.ipc : null;
export const App = () => {
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { restoreQueue } = useQueueControls();
useEffect(() => {
const root = document.documentElement;
@@ -38,17 +43,60 @@ export const App = () => {
// Start the mpv instance on startup
useEffect(() => {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
volume: usePlayerStore.getState().volume,
};
if (isElectron() && playbackType === PlaybackType.LOCAL) {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
mpvPlayer?.restart({
extraParameters,
properties,
});
}, []);
mpvPlayer?.initialize({
extraParameters,
properties,
});
mpvPlayer?.volume(properties.volume);
}
return () => {
mpvPlayer?.quit();
};
}, [playbackType]);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (isElectron()) {
mpvPlayer.restoreQueue();
mpvPlayerListener.rendererSaveQueue(() => {
const { current, queue } = usePlayerStore.getState();
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
current: {
...current,
status: PlayerStatus.PAUSED,
},
queue,
};
mpvPlayer.saveQueue(stateToSave);
});
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
const playerData = restoreQueue(data);
if (playbackType === PlaybackType.LOCAL) {
mpvPlayer.setQueue(playerData, true);
}
});
}
return () => {
ipc?.removeAllListeners('renderer-player-restore-queue');
ipc?.removeAllListeners('renderer-player-save-queue');
};
}, [playbackType, restoreQueue]);
return (
<MantineProvider
+2
View File
@@ -62,7 +62,9 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
& .mantine-Button-leftIcon {
display: flex;
height: 100%;
margin-right: 0.5rem;
transform: translateY(-0.1rem);
}
.mantine-Button-rightIcon {
@@ -122,7 +122,7 @@ export const CardControls = ({
id: [itemData.id],
type: itemType,
},
play: playType || playButtonBehavior,
playType: playType || playButtonBehavior,
});
};
+1 -1
View File
@@ -21,7 +21,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
interface CardRowsProps {
data: any;
rows: CardRow<Album | Artist | AlbumArtist>[];
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
}
export const CardRows = ({ data, rows }: CardRowsProps) => {
@@ -0,0 +1,206 @@
import { Center, Stack } from '@mantine/core';
import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled, { css } from 'styled-components';
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card';
import { Skeleton } from '/@/renderer/components/skeleton';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
interface BaseGridCardProps {
controls: {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isLoading?: boolean;
}
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
.card-controls {
opacity: 0;
}
`;
const ImageContainerStyles = css`
position: relative;
display: flex;
align-items: center;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-poster-radius);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
&:hover {
&::before {
opacity: 0.5;
}
}
&:hover .card-controls {
opacity: 1;
}
`;
const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>`
${ImageContainerStyles}
`;
const ImageContainerSkeleton = styled.div`
${ImageContainerStyles}
`;
const Image = styled(SimpleImg)`
width: 100%;
max-width: 100%;
height: 100% !important;
max-height: 100%;
border: 0;
img {
height: 100%;
object-fit: cover;
}
`;
const DetailContainer = styled.div`
margin-top: 0.5rem;
`;
export const PosterCard = ({
data,
controls,
isLoading,
uniqueId,
}: BaseGridCardProps & { uniqueId: string }) => {
if (!isLoading) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
let Placeholder = RiAlbumFill;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
}
return (
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
<ImageContainer
$isFavorite={data?.userFavorite}
to={path}
>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</PosterCardContainer>
);
}
return (
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
<Skeleton
visible
radius="sm"
>
<ImageContainerSkeleton />
</Skeleton>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<Skeleton
key={`${index}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</PosterCardContainer>
);
};
@@ -0,0 +1,32 @@
import { forwardRef } from 'react';
import { Checkbox as MantineCheckbox, CheckboxProps } from '@mantine/core';
import styled from 'styled-components';
const StyledCheckbox = styled(MantineCheckbox)`
& .mantine-Checkbox-input {
background-color: var(--input-bg);
&:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
&:hover:not(:checked) {
background-color: var(--primary-color);
opacity: 0.5;
}
transition: none;
}
`;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ ...props }: CheckboxProps, ref) => {
return (
<StyledCheckbox
ref={ref}
{...props}
/>
);
},
);
@@ -6,6 +6,7 @@ import type {
MenuDropdownProps as MantineMenuDropdownProps,
} from '@mantine/core';
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
import { RiArrowLeftSFill } from 'react-icons/ri';
import styled from 'styled-components';
type MenuProps = MantineMenuProps;
@@ -31,23 +32,6 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family);
${(props) =>
props.$isActive &&
`
&::before {
content: ''; // ::before and ::after both require content
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--dropdown-menu-bg-hover);
opacity: 0.5;
z-index: -1;
}
`}
&:disabled {
opacity: 0.6;
}
@@ -62,6 +46,10 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
}
& .mantine-Menu-itemRightSection {
display: flex;
}
cursor: default;
`;
@@ -114,7 +102,7 @@ const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) =>
<StyledMenuItem
$danger={$danger}
$isActive={$isActive}
// rightSection={$isActive && <RiArrowLeftSFill size={20} />}
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
{...props}
>
{children}
@@ -1,33 +0,0 @@
import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone';
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
import styled from 'styled-components';
export type DropzoneProps = MantineDropzoneProps;
const StyledDropzone = styled(MantineDropzone)`
display: flex;
justify-content: center;
width: 100%;
height: 100%;
background: var(--input-bg);
border-radius: 5px;
opacity: 0.8;
transition: opacity 0.2s ease;
&:hover {
background: var(--input-bg);
opacity: 1;
}
& .mantine-Dropzone-inner {
display: flex;
}
`;
export const Dropzone = ({ children, ...props }: DropzoneProps) => {
return <StyledDropzone {...props}>{children}</StyledDropzone>;
};
Dropzone.Accept = StyledDropzone.Accept;
Dropzone.Idle = StyledDropzone.Idle;
Dropzone.Reject = StyledDropzone.Reject;
+278 -214
View File
@@ -1,227 +1,291 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { Group, Stack } from '@mantine/core';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, ReactNode, useRef, useState, isValidElement } from 'react';
import { Box, Group } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { AnimatePresence } from 'framer-motion';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Virtual, SwiperOptions } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Swiper as SwiperCore } from 'swiper/types';
import { PosterCard } from '/@/renderer/components/card/poster-card';
import { Album, AlbumArtist, Artist, LibraryItem, RelatedArtist } from '/@/renderer/api/types';
import { CardRoute, CardRow } from '/@/renderer/types';
import { TextTitle } from '/@/renderer/components/text-title';
import { Button } from '/@/renderer/components/button';
import { AppRoute } from '/@/renderer/router/routes';
import type { CardRow } from '/@/renderer/types';
import { Play } from '/@/renderer/types';
import styled from 'styled-components';
import { AlbumCard } from '/@/renderer/components/card';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { LibraryItem } from '/@/renderer/api/types';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
interface GridCarouselProps {
cardRows: CardRow<any>[];
children: React.ReactElement;
containerWidth: number;
data: any[] | undefined;
itemType: LibraryItem;
loading?: boolean;
pagination?: {
handleNextPage?: () => void;
handlePreviousPage?: () => void;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
itemsPerPage?: number;
};
uniqueId: string;
}
const GridCarouselContext = createContext<any>({});
const GridContainer = styled(motion.div)<{ height: number; itemsPerPage: number }>`
display: grid;
grid-auto-rows: 0;
grid-gap: 18px;
grid-template-rows: 1fr;
grid-template-columns: repeat(${(props) => props.itemsPerPage || 4}, minmax(0, 1fr));
height: ${(props) => props.height}px;
overflow: hidden;
`;
const Wrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
`;
const variants: Variants = {
animate: (custom: { direction: number; loading: boolean }) => {
return {
opacity: custom.loading ? 0.5 : 1,
scale: custom.loading ? 0.95 : 1,
transition: {
opacity: { duration: 0.2 },
x: { damping: 30, stiffness: 300, type: 'spring' },
},
x: 0,
};
},
exit: (custom: { direction: number; loading: boolean }) => {
return {
opacity: 0,
transition: {
opacity: { duration: 0.2 },
x: { damping: 30, stiffness: 300, type: 'spring' },
},
x: custom.direction > 0 ? -1000 : 1000,
};
},
initial: (custom: { direction: number; loading: boolean }) => {
return {
opacity: 0,
x: custom.direction > 0 ? 1000 : -1000,
};
},
};
const Carousel = ({ data, cardRows }: any) => {
const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
useContext(GridCarouselContext);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
return (
<Wrapper>
<AnimatePresence
custom={{ direction, loading }}
initial={false}
mode="popLayout"
>
<GridContainer
key={`carousel-${uniqueId}-${data[0]?.id}`}
animate="animate"
custom={{ direction, loading }}
exit="exit"
height={gridHeight}
initial="initial"
itemsPerPage={pagination.itemsPerPage}
variants={variants}
>
{data?.map((item: any, index: number) => (
<AlbumCard
key={`card-${uniqueId}-${index}`}
controls={{
cardRows,
itemType: itemType || LibraryItem.ALBUM,
playButtonBehavior: playButtonBehavior || Play.NOW,
route: cardRows[0]?.route || {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
}}
data={item}
handlePlayQueueAdd={handlePlayQueueAdd}
size={imageSize}
/>
))}
</GridContainer>
</AnimatePresence>
</Wrapper>
);
};
export const GridCarousel = ({
data,
loading,
cardRows,
pagination,
children,
containerWidth,
uniqueId,
itemType,
}: GridCarouselProps) => {
const [direction, setDirection] = useState(0);
const gridHeight = useMemo(
() => (containerWidth * 1.2 - 36) / (pagination?.itemsPerPage || 4),
[containerWidth, pagination?.itemsPerPage],
);
const imageSize = useMemo(() => gridHeight * 0.66, [gridHeight]);
const providerValue = useMemo(
() => ({
cardRows,
data,
direction,
gridHeight,
imageSize,
itemType,
loading,
pagination,
setDirection,
uniqueId,
}),
[cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
);
return (
<GridCarouselContext.Provider value={providerValue}>
<Stack>
{children}
{data && (
<Carousel
cardRows={cardRows}
data={data}
/>
)}
</Stack>
</GridCarouselContext.Provider>
);
};
import { usePlayButtonBehavior } from '/@/renderer/store';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { MotionStack } from '/@/renderer/components/motion';
import 'swiper/css';
interface TitleProps {
children?: React.ReactNode;
handleNext?: () => void;
handlePrev?: () => void;
label?: string | ReactNode;
pagination: {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const Title = ({ children }: TitleProps) => {
const { pagination, setDirection } = useContext(GridCarouselContext);
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
const handleNextPage = useCallback(() => {
setDirection(1);
pagination?.handleNextPage?.();
}, [pagination, setDirection]);
const handlePreviousPage = useCallback(() => {
setDirection(-1);
pagination?.handlePreviousPage?.();
}, [pagination, setDirection]);
const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
return (
<Group position="apart">
{children}
{showPaginationButtons && (
<Group spacing="sm">
<Button
compact
disabled={!pagination?.hasPreviousPage}
size="md"
variant="default"
onClick={handlePreviousPage}
>
<RiArrowLeftSLine size={15} />
</Button>
<Button
compact
size="md"
variant="default"
onClick={handleNextPage}
>
<RiArrowRightSLine size={15} />
</Button>
</Group>
{isValidElement(label) ? (
label
) : (
<TextTitle
order={2}
weight={700}
>
{label}
</TextTitle>
)}
<Group spacing="sm">
<Button
compact
disabled={!pagination.hasPreviousPage}
size="lg"
variant="default"
onClick={handlePrev}
>
<RiArrowLeftSLine />
</Button>
<Button
compact
disabled={!pagination.hasNextPage}
size="lg"
variant="default"
onClick={handleNext}
>
<RiArrowRightSLine />
</Button>
</Group>
</Group>
);
};
GridCarousel.Title = Title;
GridCarousel.Carousel = Carousel;
interface SwiperGridCarouselProps {
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
isLoading?: boolean;
itemType: LibraryItem;
route: CardRoute;
swiperProps?: SwiperOptions;
title?: {
children?: ReactNode;
hasPagination?: boolean;
icon?: ReactNode;
label: string | ReactNode;
};
uniqueId: string;
}
const variants = {
hidden: {
opacity: 0,
},
show: {
opacity: 1,
},
};
export const SwiperGridCarousel = ({
cardRows,
data,
itemType,
route,
swiperProps,
title,
isLoading,
uniqueId,
}: SwiperGridCarouselProps) => {
const { ref, width } = useElementSize();
const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const slidesPerView = width > 1500 ? 9 : width > 1200 ? 6 : width > 768 ? 5 : width > 600 ? 3 : 2;
const [pagination, setPagination] = useState({
hasNextPage: (data?.length || 0) > Math.round(slidesPerView),
hasPreviousPage: false,
});
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = useCallback(
(options: { id: string[]; isFavorite: boolean; itemType: LibraryItem; serverId: string }) => {
const { id, itemType, isFavorite, serverId } = options;
if (isFavorite) {
deleteFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
} else {
createFavoriteMutation.mutate({
query: {
id,
type: itemType,
},
serverId,
});
}
},
[createFavoriteMutation, deleteFavoriteMutation],
);
const slides = data
? data.map((el) => (
<PosterCard
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={el}
isLoading={isLoading}
uniqueId={uniqueId}
/>
))
: Array.from(Array(10).keys()).map((el) => (
<PosterCard
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={el}
isLoading={isLoading}
uniqueId={uniqueId}
/>
));
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 5));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 5));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback(
(e: SwiperCore) => {
const { slides, isEnd, isBeginning } = e;
if (isEnd || isBeginning) return;
setPagination({
hasNextPage: slidesPerView < slides.length,
hasPreviousPage: slidesPerView < slides.length,
});
},
[slidesPerView],
);
const handleOnZoomChange = useCallback(
(e: SwiperCore) => {
const { slides, isEnd, isBeginning } = e;
if (isEnd || isBeginning) return;
setPagination({
hasNextPage: slidesPerView < slides.length,
hasPreviousPage: slidesPerView < slides.length,
});
},
[slidesPerView],
);
const handleOnReachEnd = useCallback(
(e: SwiperCore) => {
const { slides } = e;
setPagination({
hasNextPage: false,
hasPreviousPage: slidesPerView < slides.length,
});
},
[slidesPerView],
);
const handleOnReachBeginning = useCallback(
(e: SwiperCore) => {
const { slides } = e;
setPagination({
hasNextPage: slidesPerView < slides.length,
hasPreviousPage: false,
});
},
[slidesPerView],
);
return (
<AnimatePresence
initial
mode="sync"
>
<Box
ref={ref}
className="grid-carousel"
>
{width ? (
<MotionStack
animate="show"
initial="hidden"
spacing="md"
variants={variants}
>
{title && (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
)}
<Swiper
ref={swiperRef}
modules={[Virtual]}
slidesPerView={swiperProps?.slidesPerView || slidesPerView || 5}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
>
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
</MotionStack>
) : null}
</Box>
</AnimatePresence>
);
};
+1 -4
View File
@@ -5,9 +5,7 @@ export * from './button';
export * from './card';
export * from './date-picker';
export * from './dropdown-menu';
export * from './dropzone';
export * from './feature-carousel';
export * from './grid-carousel';
export * from './input';
export * from './modal';
export * from './page-header';
@@ -27,11 +25,10 @@ export * from './text';
export * from './text-title';
export * from './toast';
export * from './tooltip';
export * from './virtual-grid';
export * from './virtual-table';
export * from './motion';
export * from './context-menu';
export * from './query-builder';
export * from './rating';
export * from './hover-card';
export * from './option';
export * from './checkbox';
@@ -3,6 +3,8 @@ import { TextInputProps } from '@mantine/core';
import { useFocusWithin, useHotkeys, useMergedRef } from '@mantine/hooks';
import { RiSearchLine } from 'react-icons/ri';
import { TextInput } from '/@/renderer/components/input';
import { useSettingsStore } from '/@/renderer/store';
import { shallow } from 'zustand/shallow';
interface SearchInputProps extends TextInputProps {
initialWidth?: number;
@@ -18,18 +20,12 @@ export const SearchInput = ({
}: SearchInputProps) => {
const { ref, focused } = useFocusWithin();
const mergedRef = useMergedRef<HTMLInputElement>(ref);
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
const isOpened = focused || ref.current?.value;
const showIcon = !isOpened || (openedWidth || 100) > 100;
useHotkeys([
[
'ctrl+F',
() => {
ref.current.select();
},
],
]);
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Escape') {
+1 -28
View File
@@ -3,35 +3,8 @@ import { Skeleton as MantineSkeleton } from '@mantine/core';
import styled from 'styled-components';
const StyledSkeleton = styled(MantineSkeleton)`
@keyframes run {
0% {
left: 0;
transform: translateX(-100%);
}
80% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
&::before {
background: var(--skeleton-bg);
}
&::after {
position: absolute;
background: linear-gradient(90deg, transparent, var(--skeleton-bg), transparent);
transform: translateX(-100%);
animation-name: run;
animation-duration: 1.5s;
animation-timing-function: linear;
animation-iteration-count: infinite;
content: '';
inset: 0;
background: var(--placeholder-bg);
}
`;
-1
View File
@@ -23,7 +23,6 @@ const StyledText = styled(MantineText)<TextProps>`
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
font-family: ${(props) => props.font};
cursor: ${(props) => props.$link && 'cursor'};
transition: color 0.2s ease-in-out;
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
+1 -1
View File
@@ -30,7 +30,7 @@ const showToast = ({ type, ...props }: NotificationProps) => {
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 4000 : 2000;
const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({
autoClose: defaultDuration,
@@ -1,4 +1,7 @@
import { Center, Stack } from '@mantine/core';
import { RiAlbumFill, RiUserVoiceFill, RiPlayListFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import { ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
@@ -63,7 +66,7 @@ const InnerCardContainer = styled.div`
}
`;
const ImageContainer = styled.div`
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
position: relative;
display: flex;
align-items: center;
@@ -86,14 +89,35 @@ const ImageContainer = styled.div`
content: '';
user-select: none;
}
${(props) =>
props.$isFavorite &&
`
&::after {
position: absolute;
top: -50px;
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
}
`}
`;
const Image = styled.img`
const Image = styled(SimpleImg)`
width: 100%;
max-width: 100%;
height: auto;
object-fit: contain;
height: 100% !important;
max-height: 100%;
border: 0;
img {
height: 100%;
object-fit: cover;
}
`;
const DetailContainer = styled.div`
@@ -120,17 +144,55 @@ export const DefaultCard = ({
}, {}),
);
let Placeholder = RiAlbumFill;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
}
return (
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
onClick={() => navigate(path)}
>
<InnerCardContainer>
<ImageContainer>
<Image
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={data?.imageUrl}
/>
<ImageContainer $isFavorite={data?.userFavorite}>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@@ -161,6 +223,18 @@ export const DefaultCard = ({
radius="sm"
/>
</ImageContainer>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row, index) => (
<Skeleton
key={`${index}-${columnIndex}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
/>
))}
</Stack>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
@@ -1,5 +1,4 @@
import type { MouseEvent } from 'react';
import React from 'react';
import React, { MouseEvent, useState } from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import styled from 'styled-components';
@@ -62,7 +61,7 @@ const SecondaryButton = styled(_Button)`
}
`;
const GridCardControlsContainer = styled.div`
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
position: absolute;
z-index: 100;
display: flex;
@@ -73,6 +72,19 @@ const GridCardControlsContainer = styled.div`
height: 100%;
`;
const FavoriteBanner = styled.div`
position: absolute;
top: -50px;
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
`;
const ControlsRow = styled.div`
width: 100%;
height: calc(100% / 3);
@@ -100,11 +112,17 @@ export const GridCardControls = ({
handlePlayQueueAdd,
handleFavorite,
}: {
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handleFavorite: (options: {
id: string[];
isFavorite: boolean;
itemType: LibraryItem;
serverId: string;
}) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
@@ -116,11 +134,11 @@ export const GridCardControls = ({
id: [itemData.id],
type: itemType,
},
play: playType || playButtonBehavior,
playType: playType || playButtonBehavior,
});
};
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>) => {
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
e.preventDefault();
e.stopPropagation();
@@ -128,7 +146,10 @@ export const GridCardControls = ({
id: [itemData.id],
isFavorite: itemData.userFavorite,
itemType,
serverId,
});
setIsFavorite(!isFavorite);
};
const handleContextMenu = useHandleGeneralContextMenu(
@@ -137,42 +158,48 @@ export const GridCardControls = ({
);
return (
<GridCardControlsContainer className="card-controls">
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<SecondaryButton
p={5}
variant="subtle"
onClick={handleFavorites}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.userFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMoreFill
color="white"
size={20}
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
<>
{isFavorite ? <FavoriteBanner /> : null}
<GridCardControlsContainer
$isFavorite
className="card-controls"
>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => handleFavorites(e, itemData?.serverId)}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMoreFill
color="white"
size={20}
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
</>
);
};
@@ -1,5 +1,7 @@
import { Stack } from '@mantine/core';
import { Center, Stack } from '@mantine/core';
import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import { ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
@@ -42,11 +44,10 @@ const LinkContainer = styled.div`
cursor: pointer;
`;
const ImageContainer = styled.div`
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
position: relative;
display: flex;
align-items: center;
height: 100%;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--card-default-bg);
@@ -66,6 +67,23 @@ const ImageContainer = styled.div`
user-select: none;
}
${(props) =>
props.$isFavorite &&
`
&::after {
position: absolute;
top: -50px;
left: -50px;
width: 80px;
height: 80px;
background-color: var(--primary-color);
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
transform: rotate(-45deg);
content: '';
pointer-events: none;
}
`}
&:hover {
&::before {
opacity: 0.5;
@@ -77,12 +95,17 @@ const ImageContainer = styled.div`
}
`;
const Image = styled.img`
const Image = styled(SimpleImg)`
width: 100%;
max-width: 100%;
height: auto;
object-fit: cover;
height: 100% !important;
max-height: 100%;
border: 0;
img {
height: 100%;
object-fit: cover;
}
`;
const DetailContainer = styled.div`
@@ -109,14 +132,51 @@ export const PosterCard = ({
}, {}),
);
let Placeholder = RiAlbumFill;
switch (controls.itemType) {
case LibraryItem.ALBUM:
Placeholder = RiAlbumFill;
break;
case LibraryItem.ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.ALBUM_ARTIST:
Placeholder = RiUserVoiceFill;
break;
case LibraryItem.PLAYLIST:
Placeholder = RiPlayListFill;
break;
default:
Placeholder = RiAlbumFill;
break;
}
return (
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
<LinkContainer onClick={() => navigate(path)}>
<ImageContainer>
<Image
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
<ImageContainer $isFavorite={data?.userFavorite}>
{data?.imageUrl ? (
<Image
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<Placeholder
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
@@ -148,9 +208,9 @@ export const PosterCard = ({
</Skeleton>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row) => (
{controls.cardRows.map((row, index) => (
<Skeleton
key={row.arrayProperty}
key={`${index}-${columnIndex}-${row.arrayProperty}`}
visible
height={14}
radius="sm"
@@ -57,13 +57,16 @@ export const VirtualGridWrapper = ({
itemData,
route,
onScroll,
height,
width,
...rest
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children' | 'height' | 'width'> & {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
columnCount: number;
display: ListDisplayType;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
height?: number;
itemData: any[];
itemGap: number;
itemHeight: number;
@@ -72,6 +75,7 @@ export const VirtualGridWrapper = ({
refInstance: Ref<any>;
route?: CardRoute;
rowCount: number;
width?: number;
}) => {
const memoizedItemData = createItemData(
cardRows,
@@ -94,11 +98,13 @@ export const VirtualGridWrapper = ({
<FixedSizeList
ref={refInstance}
{...rest}
height={(height && Number(height)) || 0}
initialScrollOffset={initialScrollOffset}
itemCount={rowCount}
itemData={memoizedItemData}
itemSize={itemHeight}
overscanCount={5}
width={(width && Number(width)) || 0}
onScroll={memoizedOnScroll}
>
{GridCard}
@@ -21,18 +21,21 @@ export type VirtualInfiniteGridRef = {
setItemData: (data: any[]) => void;
};
interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
interface VirtualGridProps
extends Omit<FixedSizeListProps, 'children' | 'itemSize' | 'height' | 'width'> {
cardRows: CardRow<any>[];
display?: ListDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
height?: number;
itemGap: number;
itemSize: number;
itemType: LibraryItem;
loading?: boolean;
minimumBatchSize?: number;
route?: CardRoute;
width?: number;
}
export const VirtualInfiniteGrid = forwardRef(
@@ -1,50 +1,13 @@
/* eslint-disable import/no-cycle */
import type { ICellRendererParams } from '@ag-grid-community/core';
import { RiHeartFill, RiHeartLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useMutation } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { RawFavoriteResponse, FavoriteArgs, LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
const useCreateFavorite = () => {
const server = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
mutationFn: (args) => api.controller.createFavorite({ ...args, server }),
onSuccess: (_data, variables) => {
for (const id of variables.query.id) {
// Set the userFavorite property to true for the album in the album list data store
if (variables.query.type === LibraryItem.ALBUM) {
setAlbumListData(id, { userFavorite: true });
}
}
},
});
};
const useDeleteFavorite = () => {
const server = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
mutationFn: (args) => api.controller.deleteFavorite({ ...args, server }),
onSuccess: (_data, variables) => {
for (const id of variables.query.id) {
// Set the userFavorite property to false for the album in the album list data store
if (variables.query.type === LibraryItem.ALBUM) {
setAlbumListData(id, { userFavorite: false });
}
}
},
});
};
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
const createMutation = useCreateFavorite();
const deleteMutation = useDeleteFavorite();
const createMutation = useCreateFavorite({});
const deleteMutation = useDeleteFavorite({});
const handleToggleFavorite = () => {
const newFavoriteValue = !value;
@@ -56,6 +19,7 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
id: [data.id],
type: data.itemType,
},
serverId: data.serverId,
},
{
onSuccess: () => {
@@ -70,6 +34,7 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
id: [data.id],
type: data.itemType,
},
serverId: data.serverId,
},
{
onSuccess: () => {
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { ICellRendererParams } from '@ag-grid-community/core';
import { Group } from '@mantine/core';
import { RiCheckboxBlankLine, RiCheckboxLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button } from '/@/renderer/components/button';
import { Paper } from '/@/renderer/components/paper';
import { getNodesByDiscNumber, setNodeSelection } from '../utils';
const Container = styled(Paper)`
padding: 0.5rem 1rem;
border: 1px solid transparent;
`;
export const FullWidthDiscCell = ({ node, data, api }: ICellRendererParams) => {
const [isSelected, setIsSelected] = useState(false);
const handleToggleDiscNodes = () => {
if (!data) return;
const discNumber = Number(node.data.id.split('-')[1]);
const nodes = getNodesByDiscNumber({ api, discNumber });
setNodeSelection({ isSelected: !isSelected, nodes });
setIsSelected((prev) => !prev);
};
return (
<Container>
<Group
position="apart"
w="100%"
>
<Button
compact
leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />}
size="md"
variant="subtle"
onClick={handleToggleDiscNodes}
>
{data.name}
</Button>
</Group>
</Container>
);
};
@@ -1,22 +1,23 @@
/* eslint-disable import/no-cycle */
import { MouseEvent } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Rating } from '/@/renderer/components/rating';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useUpdateRating } from '/@/renderer/components/virtual-table/hooks/use-rating';
import { useSetRating } from '/@/renderer/features/shared';
export const RatingCell = ({ value, node }: ICellRendererParams) => {
const updateRatingMutation = useUpdateRating();
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!value) return;
updateRatingMutation.mutate(
{
_serverId: value?.serverId,
query: {
item: [value],
rating,
},
serverId: value?.serverId,
},
{
onSuccess: () => {
@@ -31,11 +32,11 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
e.stopPropagation();
updateRatingMutation.mutate(
{
_serverId: value?.serverId,
query: {
item: [value],
rating: 0,
},
serverId: value?.serverId,
},
{
onSuccess: () => {
@@ -1,40 +1,35 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
import {
RawRatingResponse,
RatingArgs,
SetRatingArgs,
Album,
AlbumArtist,
LibraryItem,
AnyLibraryItems,
RatingResponse,
} from '/@/renderer/api/types';
import {
useCurrentServer,
useSetAlbumListItemDataById,
useSetQueueRating,
useAuthStore,
} from '/@/renderer/store';
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
export const useUpdateRating = () => {
const queryClient = useQueryClient();
const currentServer = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
const setQueueRating = useSetQueueRating();
return useMutation<
RawRatingResponse,
HTTPError,
Omit<RatingArgs, 'server'>,
RatingResponse,
AxiosError,
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
{ previous: { items: AnyLibraryItems } | undefined }
>({
mutationFn: (args) => {
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
return api.controller.updateRating({ ...args, server });
const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found');
return api.controller.updateRating({ ...args, apiClientProps: { server } });
},
onError: (_error, _variables, context) => {
for (const item of context?.previous?.items || []) {
@@ -1,3 +1,4 @@
/* eslint-disable import/no-cycle */
import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
import type {
ICellRendererParams,
@@ -9,6 +10,7 @@ import type {
NewColumnsLoadedEvent,
GridReadyEvent,
GridSizeChangedEvent,
ModelUpdatedEvent,
} from '@ag-grid-community/core';
import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
@@ -28,13 +30,14 @@ import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers
import { AppRoute } from '/@/renderer/router/routes';
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
import { TableColumn } from '/@/renderer/types';
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
export * from './table-config-dropdown';
export * from './table-pagination';
export * from './hooks/use-fixed-table-header';
export * from './hooks/use-click-outside-deselect';
export * from './utils';
const TableWrapper = styled.div`
display: flex;
@@ -318,6 +321,7 @@ export const getColumnDefs = (columns: PersistedTableColumn[]) => {
columnDefs.push({
...presetColumn,
initialWidth: column.width,
...column.extraProps,
});
}
}
@@ -417,6 +421,14 @@ export const VirtualTable = forwardRef(
[autoFitColumns, onGridSizeChanged],
);
const handleModelUpdated = useCallback(
(e: ModelUpdatedEvent) => {
if (!e?.api) return;
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns],
);
return (
<TableWrapper
ref={deselectRef}
@@ -446,6 +458,7 @@ export const VirtualTable = forwardRef(
onColumnMoved={handleColumnMoved}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChanged}
onModelUpdated={handleModelUpdated}
onNewColumnsLoaded={handleNewColumnsLoaded}
/>
</TableWrapper>
@@ -0,0 +1,36 @@
import { GridApi, RowNode } from '@ag-grid-community/core';
export const getNodesByDiscNumber = (args: { api: GridApi; discNumber: number }) => {
const { api, discNumber } = args;
const nodes: RowNode<any>[] = [];
api.forEachNode((node) => {
if (node.data.discNumber === discNumber) nodes.push(node);
});
return nodes;
};
export const setNodeSelection = (args: {
deselectAll?: boolean;
isSelected: boolean;
nodes: RowNode<any>[];
}) => {
const { nodes, isSelected } = args;
nodes.forEach((node) => {
node.setSelected(isSelected);
});
};
export const toggleNodeSelection = (args: { nodes: RowNode<any>[] }) => {
const { nodes } = args;
nodes.forEach((node) => {
if (node.isSelected()) {
node.setSelected(false);
} else {
node.setSelected(true);
}
});
};
@@ -1,7 +1,8 @@
import { Center, Stack, Group, Divider, Box } from '@mantine/core';
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line } from 'react-icons/ri';
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line, RiMenuFill } from 'react-icons/ri';
import { useNavigate, useRouteError } from 'react-router';
import { Button, Text } from '/@/renderer/components';
import { Button, DropdownMenu, Text } from '/@/renderer/components';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
import { AppRoute } from '/@/renderer/router/routes';
const RouteErrorBoundary = () => {
@@ -54,6 +55,23 @@ const RouteErrorBoundary = () => {
>
Go home
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
>
Menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group grow>
<Button
size="md"
variant="filled"
@@ -1,18 +1,10 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import {
Button,
getColumnDefs,
GridCarousel,
Text,
TextTitle,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components';
import { Button } from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { RiDiscFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
import { Link } from 'react-router-dom';
@@ -33,9 +25,17 @@ import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/fe
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCurrentServer } from '/@/renderer/store';
import {
getColumnDefs,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components/virtual-table';
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
const isFullWidthRow = (node: RowNode) => {
return node.id?.includes('disc-');
return node.id?.startsWith('disc-');
};
const ContentContainer = styled.div`
@@ -60,7 +60,8 @@ interface AlbumDetailContentProps {
export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const { albumId } = useParams() as { albumId: string };
const detailQuery = useAlbumDetail({ id: albumId });
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
@@ -125,16 +126,11 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
}
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
if (uniqueDiscNumbers.size === 1) {
return detailQuery.data?.songs;
}
const rowData: (QueueSong | { id: string; name: string })[] = [];
for (const discNumber of uniqueDiscNumbers.values()) {
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
rowData.push({ id: `disc-${discNumber}`, name: `DISC ${discNumber}` });
rowData.push({ id: `disc-${discNumber}`, name: `Disc ${discNumber}`.toLocaleUpperCase() });
rowData.push(...songsByDiscNumber);
}
@@ -165,26 +161,30 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const artistQuery = useAlbumList(
{
jfParams: {
albumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
},
limit: itemsPerPage,
ndParams: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
startIndex: pagination.artist * itemsPerPage,
},
{
const artistQuery = useAlbumList({
options: {
cacheTime: 1000 * 60,
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
keepPreviousData: true,
staleTime: 1000 * 60,
},
);
query: {
_custom: {
jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id,
},
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
},
limit: 10,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
startIndex: pagination.artist * itemsPerPage,
},
serverId: server?.id,
});
const carousels = [
{
@@ -196,14 +196,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
hasPreviousPage: pagination.artist > 0,
itemsPerPage,
},
title: (
<TextTitle
order={2}
weight={700}
>
More from this artist
</TextTitle>
),
title: 'More from this artist',
uniqueId: 'mostPlayed',
},
];
@@ -213,22 +206,30 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const handlePlay = async (playType?: Play) => {
handlePlayQueueAdd?.({
byData: detailQuery?.data?.songs,
play: playType || playButtonBehavior,
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
if (!e.data || e.node.isFullWidthCell()) return;
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data || node.isFullWidthCell()) return;
rowData.push(node.data);
});
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = () => {
if (!detailQuery?.data) return;
@@ -239,6 +240,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
@@ -246,6 +248,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
serverId: detailQuery.data.serverId,
});
}
};
@@ -321,31 +324,21 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
</Group>
</Box>
)}
<Box ref={tableContainerRef}>
<Box
ref={tableContainerRef}
style={{ minHeight: '300px' }}
>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
enableCellChangeFlash={false}
fullWidthCellRenderer={(data: any) => {
if (!data.data) return null;
return (
<Group
align="center"
h="100%"
spacing="sm"
>
<RiDiscFill />
<Text>{data.data.name}</Text>
</Group>
);
}}
fullWidthCellRenderer={FullWidthDiscCell}
getRowHeight={getRowHeight}
getRowId={(data) => data.data.id}
isFullWidthRow={(data) => {
@@ -365,36 +358,45 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
ref={cq.ref}
mt="5rem"
>
{carousels.map((carousel, index) => (
<GridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={carousel.data}
itemType={LibraryItem.ALBUM}
loading={carousel.loading}
pagination={carousel.pagination}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
))}
<>
{cq.height || cq.width ? (
<>
{carousels.map((carousel, index) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
data={carousel.data}
isLoading={carousel.loading}
itemType={LibraryItem.ALBUM}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
/>
))}
</>
) : null}
</>
</Stack>
</ContentContainer>
);
@@ -3,11 +3,12 @@ import { forwardRef, Fragment, Ref } from 'react';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Button, Rating, Text } from '/@/renderer/components';
import { Rating, Text } from '/@/renderer/components';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
interface AlbumDetailHeaderProps {
@@ -17,7 +18,8 @@ interface AlbumDetailHeaderProps {
export const AlbumDetailHeader = forwardRef(
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumId } = useParams() as { albumId: string };
const detailQuery = useAlbumDetail({ id: albumId });
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const cq = useContainerQuery();
const metadataItems = [
@@ -38,17 +40,17 @@ export const AlbumDetailHeader = forwardRef(
},
];
const updateRatingMutation = useUpdateRating();
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
updateRatingMutation.mutate({
_serverId: detailQuery?.data.serverId,
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery.data.serverId,
});
};
@@ -56,11 +58,11 @@ export const AlbumDetailHeader = forwardRef(
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
updateRatingMutation.mutate({
_serverId: detailQuery.data.serverId,
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
@@ -96,26 +98,27 @@ export const AlbumDetailHeader = forwardRef(
)}
</Group>
<Group
spacing="sm"
spacing="md"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Button
<Text
key={`artist-${artist.id}`}
$link
component={Link}
size="sm"
fw={600}
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="outline"
variant="subtle"
>
{artist.name}
</Button>
</Text>
))}
</Group>
</Stack>
@@ -1,12 +1,4 @@
import {
ALBUM_CARD_ROWS,
getColumnDefs,
TablePagination,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
VirtualInfiniteGridRef,
VirtualTable,
} from '/@/renderer/components';
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import { ListDisplayType, CardRow } from '/@/renderer/types';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -40,6 +32,12 @@ import { generatePath, useNavigate } from 'react-router';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import {
VirtualInfiniteGridRef,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
interface AlbumListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -71,29 +69,36 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
limit,
startIndex,
...filter,
jfParams: {
...filter.jfParams,
},
ndParams: {
...filter.ndParams,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
if (!server) {
return params.failCallback();
}
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
@@ -165,15 +170,21 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
const fetch = useCallback(
async ({ skip, take }: { skip: number; take: number }) => {
if (!server) {
return [];
}
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
jfParams: {
...filter.jfParams,
},
ndParams: {
...filter.ndParams,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
},
},
};
@@ -181,13 +192,15 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
);
return api.normalize.albumList(albums, server);
return albums;
},
[filter, queryClient, server],
);
@@ -268,8 +281,8 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
};
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = (options: {
id: string[];
@@ -283,6 +296,7 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
id,
type: itemType,
},
serverId: server?.id,
});
} else {
createFavoriteMutation.mutate({
@@ -290,6 +304,7 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
id,
type: itemType,
},
serverId: server?.id,
});
}
};
@@ -20,16 +20,7 @@ import {
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
ALBUM_TABLE_COLUMNS,
Button,
DropdownMenu,
MultiSelect,
Slider,
Switch,
Text,
VirtualInfiniteGridRef,
} from '/@/renderer/components';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
@@ -43,6 +34,8 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
const FILTERS = {
jellyfin: [
@@ -100,7 +93,7 @@ export const AlbumListHeaderFilters = ({
const { display, filter, table, grid } = useAlbumListStore({ id, key: pageKey });
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders();
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
(server?.type &&
@@ -115,13 +108,15 @@ export const AlbumListHeaderFilters = ({
limit: take,
startIndex: skip,
...filters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
...customFilters,
};
@@ -132,14 +127,16 @@ export const AlbumListHeaderFilters = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumList(albums, server);
return albums;
},
[customFilters, queryClient, server],
);
@@ -157,13 +154,15 @@ export const AlbumListHeaderFilters = ({
startIndex,
...filters,
...customFilters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
@@ -173,15 +172,16 @@ export const AlbumListHeaderFilters = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
@@ -218,6 +218,7 @@ export const AlbumListHeaderFilters = ({
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
serverId={server?.id}
/>
) : (
<JellyfinAlbumFilters
@@ -225,6 +226,7 @@ export const AlbumListHeaderFilters = ({
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
serverId={server?.id}
/>
)}
</>
@@ -251,6 +253,7 @@ export const AlbumListHeaderFilters = ({
sortBy: e.currentTarget.value as AlbumListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
@@ -267,11 +270,13 @@ export const AlbumListHeaderFilters = ({
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
}
@@ -285,6 +290,7 @@ export const AlbumListHeaderFilters = ({
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -292,38 +298,40 @@ export const AlbumListHeaderFilters = ({
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlay = async (play: Play) => {
if (!itemCount || itemCount === 0) return;
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0 || !server) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
jfParams: {
...filter.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filter.ndParams,
...customFilters?.ndParams,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
const albumIds =
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
const albumIds = albumListRes?.items?.map((a) => a.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
play,
playType,
});
};
@@ -382,16 +390,16 @@ export const AlbumListHeaderFilters = ({
const isFilterApplied = useMemo(() => {
const isNavidromeFilterApplied =
server?.type === ServerType.NAVIDROME &&
filter.ndParams &&
Object.values(filter.ndParams).some((value) => value !== undefined);
filter?._custom?.navidrome &&
Object.values(filter?._custom?.navidrome).some((value) => value !== undefined);
const isJellyfinFilterApplied =
server?.type === ServerType.JELLYFIN &&
filter.jfParams &&
Object.values(filter.jfParams).some((value) => value !== undefined);
filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter.jfParams, filter.ndParams, server?.type]);
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
return (
<Flex justify="space-between">
@@ -456,7 +464,7 @@ export const AlbumListHeaderFilters = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
@@ -9,7 +9,7 @@ import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
@@ -24,6 +24,7 @@ import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/a
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
interface AlbumListHeaderProps {
customFilters?: Partial<AlbumListFilter>;
@@ -54,15 +55,17 @@ export const AlbumListHeader = ({
limit: take,
startIndex: skip,
...filters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
},
...customFilters,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
@@ -71,14 +74,16 @@ export const AlbumListHeader = ({
queryKey,
async ({ signal }) =>
controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumList(albums, server);
return albums;
},
[customFilters, queryClient, server],
);
@@ -96,13 +101,15 @@ export const AlbumListHeader = ({
startIndex,
...filters,
...customFilters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
_custom: {
jellyfin: {
...filters._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filters._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
@@ -112,15 +119,16 @@ export const AlbumListHeader = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
@@ -150,45 +158,51 @@ export const AlbumListHeader = ({
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({ data: { searchTerm }, key: 'album' }) as AlbumListFilter;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (play: Play) => {
const handlePlay = async (playType: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
jfParams: {
...filter.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filter.ndParams,
...customFilters?.ndParams,
_custom: {
jellyfin: {
...filter._custom?.jellyfin,
...customFilters?._custom?.jellyfin,
},
navidrome: {
...filter._custom?.navidrome,
...customFilters?._custom?.navidrome,
},
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
queryFn: ({ signal }) =>
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
queryKey,
});
const albumIds =
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
play,
playType,
});
};
@@ -4,7 +4,7 @@ import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
interface JellyfinAlbumFiltersProps {
@@ -12,6 +12,7 @@ interface JellyfinAlbumFiltersProps {
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
}
export const JellyfinAlbumFilters = ({
@@ -19,24 +20,25 @@ export const JellyfinAlbumFilters = ({
handleFilterChange,
pageKey,
id,
serverId,
}: JellyfinAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList(null);
const genreListQuery = useGenreList({ query: null, serverId });
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.map((genre) => ({
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter.jfParams?.genreIds?.split(',');
}, [filter.jfParams?.genreIds]);
return filter._custom?.jellyfin?.GenreIds?.split(',');
}, [filter._custom?.jellyfin?.GenreIds]);
const toggleFilters = [
{
@@ -44,17 +46,20 @@ export const JellyfinAlbumFilters = ({
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
includeItemTypes: 'Audio',
isFavorite: e.currentTarget.checked ? true : undefined,
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.jfParams?.isFavorite,
value: filter._custom?.jellyfin?.IsFavorite,
},
];
@@ -62,11 +67,15 @@ export const JellyfinAlbumFilters = ({
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
minYear: e === '' ? undefined : (e as number),
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -76,11 +85,15 @@ export const JellyfinAlbumFilters = ({
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
maxYear: e === '' ? undefined : (e as number),
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -90,11 +103,15 @@ export const JellyfinAlbumFilters = ({
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
genreIds: genreFilterString,
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -102,17 +119,18 @@ export const JellyfinAlbumFilters = ({
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const albumArtistListQuery = useAlbumArtistList(
{
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
{
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
);
serverId,
});
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
@@ -127,11 +145,15 @@ export const JellyfinAlbumFilters = ({
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
albumArtistIds: albumArtistFilterString,
_custom: {
...filter._custom,
jellyfin: {
...filter._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -155,21 +177,21 @@ export const JellyfinAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.jfParams?.minYear}
defaultValue={filter._custom?.jellyfin?.minYear}
hideControls={false}
label="From year"
max={2300}
min={1700}
required={!!filter.jfParams?.maxYear}
required={!!filter._custom?.jellyfin?.maxYear}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter.jfParams?.maxYear}
defaultValue={filter._custom?.jellyfin?.maxYear}
hideControls={false}
label="To year"
max={2300}
min={1700}
required={!!filter.jfParams?.minYear}
required={!!filter._custom?.jellyfin?.minYear}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
@@ -189,7 +211,7 @@ export const JellyfinAlbumFilters = ({
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter.jfParams?.albumArtistIds?.split(',')}
defaultValue={filter._custom?.jellyfin?.AlbumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
limit={300}
@@ -5,13 +5,14 @@ import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/ren
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
interface NavidromeAlbumFiltersProps {
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
serverId?: string;
}
export const NavidromeAlbumFilters = ({
@@ -19,15 +20,16 @@ export const NavidromeAlbumFilters = ({
disableArtistFilter,
pageKey,
id,
serverId,
}: NavidromeAlbumFiltersProps) => {
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList(null);
const genreListQuery = useGenreList({ query: null, serverId });
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.map((genre) => ({
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
@@ -36,11 +38,15 @@ export const NavidromeAlbumFilters = ({
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
genre_id: e || undefined,
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -52,72 +58,96 @@ export const NavidromeAlbumFilters = ({
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
has_rating: e.currentTarget.checked ? true : undefined,
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
has_rating: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.has_rating,
value: filter._custom?.navidrome?.has_rating,
},
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
console.log('e.currentTarget.checked :>> ', e.currentTarget.checked);
const updatedFilters = setFilter({
data: {
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.starred,
value: filter._custom?.navidrome?.starred,
},
{
label: 'Is compilation',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
compilation: e.currentTarget.checked ? true : undefined,
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.compilation,
value: filter._custom?.navidrome?.compilation,
},
{
label: 'Is recently played',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
recently_played: e.currentTarget.checked ? true : undefined,
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
recently_played: e.currentTarget.checked ? true : undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.recently_played,
value: filter._custom?.navidrome?.recently_played,
},
];
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
year: e === '' ? undefined : (e as number),
_custom: {
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
...filter._custom,
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -125,18 +155,19 @@ export const NavidromeAlbumFilters = ({
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const albumArtistListQuery = useAlbumArtistList(
{
const albumArtistListQuery = useAlbumArtistList({
options: {
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
// searchTerm: debouncedSearchTerm,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
{
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
);
serverId,
});
const selectableAlbumArtists = useMemo(() => {
if (!albumArtistListQuery?.data?.items) return [];
@@ -150,11 +181,15 @@ export const NavidromeAlbumFilters = ({
const handleAlbumArtistFilter = (e: string | null) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
artist_id: e || undefined,
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
artist_id: e || undefined,
},
},
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
@@ -177,7 +212,7 @@ export const NavidromeAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.ndParams?.year}
defaultValue={filter._custom?.navidrome?.year}
hideControls={false}
label="Year"
max={5000}
@@ -188,7 +223,7 @@ export const NavidromeAlbumFilters = ({
clearable
searchable
data={genreList}
defaultValue={filter.ndParams?.genre_id}
defaultValue={filter._custom?.navidrome?.genre_id}
label="Genre"
onChange={handleGenresFilter}
/>
@@ -198,7 +233,7 @@ export const NavidromeAlbumFilters = ({
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter.ndParams?.artist_id}
defaultValue={filter._custom?.navidrome?.artist_id}
disabled={disableArtistFilter}
label="Artist"
limit={300}
@@ -1,22 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '../../../store/auth.store';
import type { AlbumDetailQuery, RawAlbumDetailResponse } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '../../../store/auth.store';
import type { AlbumDetailQuery } from '/@/renderer/api/types';
import { controller } from '/@/renderer/api/controller';
import { useCallback } from 'react';
import { api } from '/@/renderer/api';
export const useAlbumDetail = (query: AlbumDetailQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useAlbumDetail = (args: QueryHookArgs<AlbumDetailQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
queryFn: ({ signal }) => controller.getAlbumDetail({ query, server, signal }),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albums.detail(server?.id || '', query),
select: useCallback(
(data: RawAlbumDetailResponse | undefined) => api.normalize.albumDetail(data, server),
[server],
),
...options,
});
};
@@ -1,40 +1,61 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumListQuery, RawAlbumListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import type { AlbumListQuery, AlbumListResponse } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useAlbumList = (query: AlbumListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useAlbumList = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => controller.getAlbumList({ query, server, signal }),
queryKey: queryKeys.albums.list(server?.id || '', query),
select: useCallback(
(data: RawAlbumListResponse | undefined) => api.normalize.albumList(data, server),
[server],
),
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.albums.list(serverId || '', query),
...options,
});
};
// export const useAlbumListInfinite = (params: AlbumListParams, options?: QueryOptions) => {
// const serverId = useAuthStore((state) => state.currentServer?.id) || '';
export const useAlbumListInfinite = (args: QueryHookArgs<AlbumListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
// return useInfiniteQuery({
// enabled: !!serverId,
// getNextPageParam: (lastPage: AlbumListResponse) => {
// return !!lastPage.pagination.nextPage;
// },
// getPreviousPageParam: (firstPage: AlbumListResponse) => {
// return !!firstPage.pagination.prevPage;
// },
// queryFn: ({ pageParam }) => api.albums.getAlbumList({ serverId }, { ...(pageParam || params) }),
// queryKey: queryKeys.albums.list(serverId, params),
// ...options,
// });
// };
return useInfiniteQuery({
enabled: !!serverId,
getNextPageParam: (lastPage: AlbumListResponse | undefined, pages) => {
if (!lastPage?.items) return undefined;
if (lastPage?.items?.length >= (query?.limit || 50)) {
return pages?.length;
}
return undefined;
},
queryFn: ({ pageParam = 0, signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query: {
...query,
limit: query.limit || 50,
startIndex: pageParam * (query.limit || 50),
},
});
},
queryKey: queryKeys.albums.list(server?.id || '', query),
...options,
});
};
@@ -10,6 +10,7 @@ import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
const AlbumDetailRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
@@ -17,7 +18,8 @@ const AlbumDetailRoute = () => {
const headerRef = useRef<HTMLDivElement>(null);
const { albumId } = useParams() as { albumId: string };
const detailQuery = useAlbumDetail({ id: albumId });
const server = useCurrentServer();
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
@@ -28,7 +30,7 @@ const AlbumDetailRoute = () => {
id: [albumId],
type: LibraryItem.ALBUM,
},
play: playButtonBehavior,
playType: playButtonBehavior,
});
};
@@ -1,4 +1,4 @@
import { VirtualInfiniteGridRef } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
@@ -24,17 +24,18 @@ const AlbumListRoute = () => {
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
const itemCountCheck = useAlbumList(
{
const itemCountCheck = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumListFilter,
},
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
@@ -1,12 +1,5 @@
import { useMemo } from 'react';
import {
Button,
getColumnDefs,
GridCarousel,
Text,
TextTitle,
VirtualTable,
} from '/@/renderer/components';
import { Button, Text, TextTitle } from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
@@ -21,7 +14,7 @@ import {
useHandleGeneralContextMenu,
useHandleTableContextMenu,
} from '/@/renderer/features/context-menu';
import { Play, TableColumn } from '/@/renderer/types';
import { CardRow, Play, TableColumn } from '/@/renderer/types';
import {
ARTIST_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
@@ -29,6 +22,8 @@ import {
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import {
Album,
AlbumArtist,
AlbumListSort,
LibraryItem,
QueueSong,
@@ -38,6 +33,8 @@ import {
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
const ContentContainer = styled.div`
position: relative;
@@ -61,9 +58,8 @@ export const AlbumArtistDetailContent = () => {
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const server = useCurrentServer();
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const artistDiscographyLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
albumArtistId,
@@ -80,34 +76,57 @@ export const AlbumArtistDetailContent = () => {
})}`;
const recentAlbumsQuery = useAlbumList({
jfParams: server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined,
limit: itemsPerPage,
ndParams:
server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN ? { ArtistIds: albumArtistId } : undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: false }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const compilationAlbumsQuery = useAlbumList({
jfParams:
server?.type === ServerType.JELLYFIN ? { contributingArtistIds: albumArtistId } : undefined,
limit: itemsPerPage,
ndParams:
server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
query: {
_custom: {
jellyfin: {
...(server?.type === ServerType.JELLYFIN
? { ContributingArtistIds: albumArtistId }
: undefined),
},
navidrome: {
...(server?.type === ServerType.NAVIDROME
? { artist_id: albumArtistId, compilation: true }
: undefined),
},
},
// limit: 10,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId: server?.id,
});
const topSongsQuery = useTopSongsList(
{ artist: detailQuery?.data?.name || '', artistId: albumArtistId },
{ enabled: !!detailQuery?.data?.name },
);
const topSongsQuery = useTopSongsList({
options: {
enabled: !!detailQuery?.data?.name,
},
query: {
artist: detailQuery?.data?.name || '',
artistId: albumArtistId,
},
serverId: server?.id,
});
const topSongsColumnDefs: ColDef[] = useMemo(
() =>
@@ -123,7 +142,7 @@ export const AlbumArtistDetailContent = () => {
[],
);
const cardRows = {
const cardRows: Record<string, CardRow<Album>[] | CardRow<AlbumArtist>[]> = {
album: [
{
property: 'name',
@@ -152,17 +171,25 @@ export const AlbumArtistDetailContent = () => {
],
};
const cardRoutes = {
album: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
albumArtist: {
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
};
const carousels = [
{
data: recentAlbumsQuery?.data?.items,
isHidden: !recentAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
pagination: {
itemsPerPage,
},
title: (
<>
<Group align="flex-end">
<TextTitle
order={2}
weight={700}
@@ -178,7 +205,7 @@ export const AlbumArtistDetailContent = () => {
>
View discography
</Button>
</>
</Group>
),
uniqueId: 'recentReleases',
},
@@ -187,9 +214,6 @@ export const AlbumArtistDetailContent = () => {
isHidden: !compilationAlbumsQuery?.data?.items?.length,
itemType: LibraryItem.ALBUM,
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
pagination: {
itemsPerPage,
},
title: (
<TextTitle
order={2}
@@ -201,13 +225,10 @@ export const AlbumArtistDetailContent = () => {
uniqueId: 'compilationAlbums',
},
{
data: detailQuery?.data?.similarArtists?.slice(0, itemsPerPage),
data: detailQuery?.data?.similarArtists || [],
isHidden: !detailQuery?.data?.similarArtists,
itemType: LibraryItem.ALBUM_ARTIST,
loading: detailQuery?.isLoading || detailQuery.isFetching,
pagination: {
itemsPerPage,
},
title: (
<TextTitle
order={2}
@@ -228,22 +249,24 @@ export const AlbumArtistDetailContent = () => {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
play: playType || playButtonBehavior,
playType: playType || playButtonBehavior,
});
};
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
if (!e.data || !topSongsQuery?.data) return;
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
byData: topSongsQuery?.data?.items || [],
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleFavorite = () => {
if (!detailQuery?.data) return;
@@ -254,6 +277,7 @@ export const AlbumArtistDetailContent = () => {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
serverId: detailQuery.data.serverId,
});
} else {
createFavoriteMutation.mutate({
@@ -261,6 +285,7 @@ export const AlbumArtistDetailContent = () => {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM_ARTIST,
},
serverId: detailQuery.data.serverId,
});
}
};
@@ -334,7 +359,7 @@ export const AlbumArtistDetailContent = () => {
</Group>
</Group>
</Box>
{showGenres && (
{showGenres ? (
<Box component="section">
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
@@ -354,7 +379,7 @@ export const AlbumArtistDetailContent = () => {
))}
</Group>
</Box>
)}
) : null}
{showBiography ? (
<Box
component="section"
@@ -374,7 +399,7 @@ export const AlbumArtistDetailContent = () => {
/>
</Box>
) : null}
{showTopSongs && (
{showTopSongs ? (
<Box component="section">
<Group
noWrap
@@ -421,24 +446,29 @@ export const AlbumArtistDetailContent = () => {
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
)}
) : null}
<Box component="section">
<Stack spacing="xl">
{carousels
.filter((c) => !c.isHidden)
.map((carousel) => (
<GridCarousel
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
containerWidth={cq.width}
data={carousel.data}
isLoading={carousel.loading}
itemType={carousel.itemType}
loading={carousel.loading}
pagination={carousel.pagination}
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
swiperProps={{
grid: {
rows: 2,
},
}}
title={{
label: carousel.title,
}}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
/>
))}
</Stack>
</Box>
@@ -1,13 +1,14 @@
import { Group, Rating, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
import { Group, Rating, Stack } from '@mantine/core';
import { useParams } from 'react-router';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDurationString } from '/@/renderer/utils';
import { useCurrentServer } from '../../../store/auth.store';
interface AlbumArtistDetailHeaderProps {
background: string;
@@ -16,7 +17,11 @@ interface AlbumArtistDetailHeaderProps {
export const AlbumArtistDetailHeader = forwardRef(
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
const { albumArtistId } = useParams() as { albumArtistId: string };
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({
query: { id: albumArtistId },
serverId: server?.id,
});
const cq = useContainerQuery();
const metadataItems = [
@@ -37,17 +42,17 @@ export const AlbumArtistDetailHeader = forwardRef(
},
];
const updateRatingMutation = useUpdateRating();
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
updateRatingMutation.mutate({
_serverId: detailQuery?.data.serverId,
query: {
item: [detailQuery.data],
rating,
},
serverId: detailQuery?.data.serverId,
});
};
@@ -58,11 +63,11 @@ export const AlbumArtistDetailHeader = forwardRef(
if (!isSameRatingAsPrevious) return;
updateRatingMutation.mutate({
_serverId: detailQuery.data.serverId,
query: {
item: [detailQuery.data],
rating: 0,
},
serverId: detailQuery.data.serverId,
});
};
@@ -1,13 +1,14 @@
import { MutableRefObject, useMemo } from 'react';
import type { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { getColumnDefs, VirtualGridAutoSizerContainer, VirtualTable } from '/@/renderer/components';
import { useCurrentServer, useSongListStore } from '/@/renderer/store';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
interface AlbumArtistSongListContentProps {
data: QueueSong[];
@@ -33,9 +34,17 @@ export const AlbumArtistDetailTopSongsListContent = ({
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
const rowData: QueueSong[] = [];
e.api.forEachNode((node) => {
if (!node.data) return;
rowData.push(node.data);
});
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
byData: rowData,
initialSongId: e.data.id,
playType: playButtonBehavior,
});
};
@@ -20,10 +20,10 @@ export const AlbumArtistDetailTopSongsListHeader = ({
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (play: Play) => {
const handlePlay = async (playType: Play) => {
handlePlayQueueAdd?.({
byData: data,
play,
playType,
});
};
@@ -1,12 +1,4 @@
import {
ALBUMARTIST_CARD_ROWS,
getColumnDefs,
TablePagination,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
VirtualInfiniteGridRef,
VirtualTable,
} from '/@/renderer/components';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import { ListDisplayType, CardRow } from '/@/renderer/types';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -35,6 +27,12 @@ import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-a
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useAlbumArtistListFilter, useListStoreActions } from '../../../store/list.store';
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
import {
VirtualInfiniteGridRef,
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,
} from '/@/renderer/components/virtual-grid';
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
interface AlbumArtistListContentProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -54,17 +52,18 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const checkAlbumArtistList = useAlbumArtistList(
{
const checkAlbumArtistList = useAlbumArtistList({
options: {
cacheTime: Infinity,
staleTime: 60 * 1000 * 5,
},
query: {
limit: 1,
startIndex: 0,
...filter,
},
{
cacheTime: Infinity,
staleTime: 60 * 1000 * 5,
},
);
serverId: server?.id,
});
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
@@ -85,19 +84,23 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albums = api.normalize.albumArtistList(albumArtistsRes, server);
params.successCallback(albums?.items || [], albumArtistsRes?.totalRecordCount || 0);
params.successCallback(
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
rowCount: undefined,
};
@@ -181,18 +184,20 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filter,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumArtistList(albumArtistsRes, server);
return albumArtistsRes;
},
[filter, queryClient, server],
);
@@ -259,27 +264,29 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AutoSizer>
{({ height, width }) => (
<VirtualInfiniteGrid
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM_ARTIST}
loading={checkAlbumArtistList.isLoading}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
<>
<VirtualInfiniteGrid
ref={gridRef}
cardRows={cardRows}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
itemGap={20}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM_ARTIST}
loading={checkAlbumArtistList.isLoading}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
</>
)}
</AutoSizer>
) : (
@@ -14,17 +14,8 @@ import {
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
import {
DropdownMenu,
ALBUMARTIST_TABLE_COLUMNS,
VirtualInfiniteGridRef,
Text,
Button,
Slider,
MultiSelect,
Switch,
} from '/@/renderer/components';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components';
import { useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
@@ -36,6 +27,8 @@ import {
} from '/@/renderer/store';
import { ListDisplayType, TableColumn, ServerType } from '/@/renderer/types';
import { useAlbumArtistListContext } from '../context/album-artist-list-context';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
const FILTERS = {
jellyfin: [
@@ -83,7 +76,7 @@ export const AlbumArtistListHeaderFilters = ({
const filter = useAlbumArtistListFilter({ key: pageKey });
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders();
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
(server?.type &&
@@ -114,18 +107,20 @@ export const AlbumArtistListHeaderFilters = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumArtistList(albums, server);
return albums;
},
[queryClient, server],
);
@@ -148,20 +143,21 @@ export const AlbumArtistListHeaderFilters = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
params.successCallback(
albumArtists?.items || [],
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
@@ -203,6 +199,7 @@ export const AlbumArtistListHeaderFilters = ({
sortBy: e.currentTarget.value as AlbumArtistListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
@@ -219,11 +216,13 @@ export const AlbumArtistListHeaderFilters = ({
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
}
@@ -237,6 +236,7 @@ export const AlbumArtistListHeaderFilters = ({
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
handleFilterChange(updatedFilters);
@@ -355,7 +355,7 @@ export const AlbumArtistListHeaderFilters = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
@@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
@@ -20,6 +20,8 @@ import { ListDisplayType } from '/@/renderer/types';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
import { FilterBar } from '../../shared/components/filter-bar';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { LibraryItem } from '/@/renderer/api/types';
interface AlbumArtistListHeaderProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
@@ -51,18 +53,20 @@ export const AlbumArtistListHeader = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumArtistList(albums, server);
return albums;
},
[queryClient, server],
);
@@ -85,20 +89,21 @@ export const AlbumArtistListHeader = ({
queryKey,
async ({ signal }) =>
api.controller.getAlbumArtistList({
apiClientProps: {
server,
signal,
},
query: {
limit,
startIndex,
...filters,
},
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
params.successCallback(
albumArtists?.items || [],
albumArtistsRes?.items || [],
albumArtistsRes?.totalRecordCount || 0,
);
},
@@ -132,6 +137,7 @@ export const AlbumArtistListHeader = ({
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({
data: { searchTerm },
itemType: LibraryItem.ALBUM_ARTIST,
key: pageKey,
}) as AlbumArtistListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
@@ -1,23 +1,21 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import type { AlbumArtistDetailQuery } from '/@/renderer/api/types';
import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistDetail = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useAlbumArtistDetail = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
select: useCallback(
(data: RawAlbumArtistDetailResponse | undefined) =>
api.normalize.albumArtistDetail(data, server),
[server],
),
...options,
});
};
@@ -1,22 +1,21 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumArtistListQuery, RawAlbumArtistListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import type { AlbumArtistListQuery } from '/@/renderer/api/types';
import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistList = (query: AlbumArtistListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useAlbumArtistList = (args: QueryHookArgs<AlbumArtistListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => api.controller.getAlbumArtistList({ query, server, signal }),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.list(server?.id || '', query),
select: useCallback(
(data: RawAlbumArtistListResponse | undefined) => api.normalize.albumArtistList(data, server),
[server],
),
...options,
});
};
@@ -1,23 +1,21 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import type { AlbumArtistDetailQuery } from '/@/renderer/api/types';
import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { QueryHookArgs } from '../../../lib/react-query';
export const useAlbumArtistInfo = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useAlbumArtistInfo = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id && !!query.id,
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
select: useCallback(
(data: RawAlbumArtistDetailResponse | undefined) =>
api.normalize.albumArtistDetail(data, server),
[server],
),
...options,
});
};
@@ -1,22 +1,21 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { RawTopSongListResponse, TopSongListQuery } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import type { TopSongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
export const useTopSongsList = (query: TopSongListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server?.id,
queryFn: ({ signal }) => api.controller.getTopSongList({ query, server, signal }),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
select: useCallback(
(data: RawTopSongListResponse | undefined) => api.normalize.topSongList(data, server),
[server],
),
...options,
});
};
@@ -9,15 +9,17 @@ import { LibraryItem } from '/@/renderer/api/types';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
import { useCurrentServer } from '/@/renderer/store';
const AlbumArtistDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const { albumArtistId } = useParams() as { albumArtistId: string };
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
const handlePlay = () => {
@@ -26,7 +28,7 @@ const AlbumArtistDetailRoute = () => {
id: [albumArtistId],
type: LibraryItem.ALBUM_ARTIST,
},
play: playButtonBehavior,
playType: playButtonBehavior,
});
};
@@ -1,22 +1,25 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useParams } from 'react-router';
import { AlbumArtistDetailTopSongsListContent } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-content';
import { AlbumArtistDetailTopSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-header';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
import { AnimatedPage } from '/@/renderer/features/shared';
import { useCurrentServer } from '../../../store/auth.store';
const AlbumArtistDetailTopSongsListRoute = () => {
const tableRef = useRef<AgGridReactType | null>(null);
const { albumArtistId } = useParams() as { albumArtistId: string };
const server = useCurrentServer();
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
const topSongsQuery = useTopSongsList(
{ artist: detailQuery?.data?.name || '', artistId: albumArtistId },
{ enabled: !!detailQuery?.data?.name },
);
const topSongsQuery = useTopSongsList({
options: { enabled: !!detailQuery?.data?.name },
query: { artist: detailQuery?.data?.name || '', artistId: albumArtistId },
serverId: server?.id,
});
const itemCount = topSongsQuery?.data?.items?.length || 0;
@@ -1,4 +1,3 @@
import { VirtualInfiniteGridRef } from '/@/renderer/components';
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
import { AnimatedPage } from '/@/renderer/features/shared';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
@@ -7,25 +6,29 @@ import { AlbumArtistListContent } from '/@/renderer/features/artists/components/
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { generatePageKey, useAlbumArtistListFilter } from '/@/renderer/store';
import { AlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
import { useCurrentServer } from '../../../store/auth.store';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
const AlbumArtistListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const pageKey = generatePageKey('albumArtist', undefined);
const server = useCurrentServer();
const albumArtistListFilter = useAlbumArtistListFilter({ id: undefined, key: pageKey });
const itemCountCheck = useAlbumArtistList(
{
const itemCountCheck = useAlbumArtistList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...albumArtistListFilter,
},
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
@@ -25,7 +25,7 @@ import {
RiStarFill,
RiCloseCircleLine,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType } from '/@/renderer/api/types';
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
import {
ConfirmModal,
ContextMenu,
@@ -43,7 +43,7 @@ import {
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
import { useAuthStore, useCurrentServer, useQueueControls } from '/@/renderer/store';
import { usePlayerType } from '/@/renderer/store/settings.store';
import { Play, PlaybackType } from '/@/renderer/types';
@@ -153,34 +153,34 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
const handlePlay = useCallback(
(play: Play) => {
(playType: Play) => {
switch (ctx.type) {
case LibraryItem.ALBUM:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
playType,
});
break;
case LibraryItem.ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
playType,
});
break;
case LibraryItem.ALBUM_ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
playType,
});
break;
case LibraryItem.SONG:
handlePlayQueueAdd?.({ byData: ctx.data, play });
handlePlayQueueAdd?.({ byData: ctx.data, playType });
break;
case LibraryItem.PLAYLIST:
for (const item of ctx.data) {
handlePlayQueueAdd?.({
byItemType: { id: [item.id], type: ctx.type },
play,
playType,
});
}
@@ -190,7 +190,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
[ctx.data, ctx.type, handlePlayQueueAdd],
);
const deletePlaylistMutation = useDeletePlaylist();
const deletePlaylistMutation = useDeletePlaylist({});
const handleDeletePlaylist = useCallback(() => {
for (const item of ctx.data) {
@@ -236,80 +236,136 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
}, [ctx.data, handleDeletePlaylist]);
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const createFavoriteMutation = useCreateFavorite({});
const deleteFavoriteMutation = useDeleteFavorite({});
const handleAddToFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
let itemsToFavorite: AnyLibraryItems = [];
let nodesToFavorite: RowNode<any>[] = [];
if (ctx.dataNodes) {
nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
const nodesByServerId = nodesToFavorite.reduce((acc, node) => {
if (!acc[node.data.serverId]) {
acc[node.data.serverId] = [];
}
acc[node.data.serverId].push(node);
return acc;
}, {} as Record<string, RowNode<any>[]>);
for (const serverId of Object.keys(nodesByServerId)) {
const nodes = nodesByServerId[serverId];
const items = nodes.map((node) => node.data);
createFavoriteMutation.mutate(
{
query: {
id: items.map((item) => item.id),
type: ctx.type,
},
serverId,
},
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
});
},
onSuccess: () => {
for (const node of nodes) {
node.setData({ ...node.data, userFavorite: true });
}
},
},
);
}
} else {
itemsToFavorite = ctx.data.filter((item) => !item.userFavorite);
const itemsToFavorite = ctx.data.filter((item) => !item.userFavorite);
const itemsByServerId = (itemsToFavorite as any[]).reduce((acc, item) => {
if (!acc[item.serverId]) {
acc[item.serverId] = [];
}
acc[item.serverId].push(item);
return acc;
}, {} as Record<string, AnyLibraryItems>);
for (const serverId of Object.keys(itemsByServerId)) {
const items = itemsByServerId[serverId];
createFavoriteMutation.mutate(
{
query: {
id: items.map((item: AnyLibraryItem) => item.id),
type: ctx.type,
},
serverId,
},
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
});
},
},
);
}
}
const idsToFavorite = nodesToFavorite
? nodesToFavorite.map((node) => node.data.id)
: itemsToFavorite.map((item) => item.id);
createFavoriteMutation.mutate(
{
query: {
id: idsToFavorite,
type: ctx.type,
},
},
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error adding to favorites',
});
},
onSuccess: () => {
if (ctx.dataNodes) {
for (const node of nodesToFavorite) {
node.setData({ ...node.data, userFavorite: true });
}
}
},
},
);
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
const handleRemoveFromFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
let itemsToUnfavorite: AnyLibraryItems = [];
let nodesToUnfavorite: RowNode<any>[] = [];
if (ctx.dataNodes) {
nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
const nodesByServerId = nodesToUnfavorite.reduce((acc, node) => {
if (!acc[node.data.serverId]) {
acc[node.data.serverId] = [];
}
acc[node.data.serverId].push(node);
return acc;
}, {} as Record<string, RowNode<any>[]>);
for (const serverId of Object.keys(nodesByServerId)) {
const idsToUnfavorite = nodesByServerId[serverId].map((node) => node.data.id);
deleteFavoriteMutation.mutate(
{
query: {
id: idsToUnfavorite,
type: ctx.type,
},
serverId,
},
{
onSuccess: () => {
for (const node of nodesToUnfavorite) {
node.setData({ ...node.data, userFavorite: false });
}
},
},
);
}
} else {
itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite);
const itemsToUnfavorite = ctx.data.filter((item) => item.userFavorite);
const itemsByServerId = (itemsToUnfavorite as any[]).reduce((acc, item) => {
if (!acc[item.serverId]) {
acc[item.serverId] = [];
}
acc[item.serverId].push(item);
return acc;
}, {} as Record<string, AnyLibraryItems>);
for (const serverId of Object.keys(itemsByServerId)) {
const idsToUnfavorite = itemsByServerId[serverId].map((item: AnyLibraryItem) => item.id);
deleteFavoriteMutation.mutate({
query: {
id: idsToUnfavorite,
type: ctx.type,
},
serverId,
});
}
}
const idsToUnfavorite = nodesToUnfavorite
? nodesToUnfavorite.map((node) => node.data.id)
: itemsToUnfavorite.map((item) => item.id);
deleteFavoriteMutation.mutate(
{
query: {
id: idsToUnfavorite,
type: ctx.type,
},
},
{
onSuccess: () => {
for (const node of nodesToUnfavorite) {
node.setData({ ...node.data, userFavorite: false });
}
},
},
);
}, [ctx.data, ctx.dataNodes, ctx.type, deleteFavoriteMutation]);
const handleAddToPlaylist = useCallback(() => {
@@ -414,7 +470,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
serverType,
]);
const updateRatingMutation = useUpdateRating();
const updateRatingMutation = useSetRating({});
const handleUpdateRating = useCallback(
(rating: number) => {
@@ -450,11 +506,11 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
updateRatingMutation.mutate(
{
_serverId: serverId,
query: {
item: items,
rating,
},
serverId,
},
{
onSuccess: () => {
@@ -16,6 +16,10 @@ export const useHandleTableContextMenu = (
let selectedNodes = sortBy(e.api.getSelectedNodes(), ['rowIndex']);
let selectedRows = selectedNodes.map((node) => node.data);
if (!e.data?.id) {
return;
}
const shouldReplaceSelected = !selectedNodes.map((node) => node.data.id).includes(e.data.id);
if (shouldReplaceSelected) {
@@ -1,24 +1,22 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { GenreListQuery, RawGenreListResponse } from '/@/renderer/api/types';
import type { QueryOptions } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import type { GenreListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useGenreList = (query: GenreListQuery, options?: QueryOptions) => {
const server = useCurrentServer();
export const useGenreList = (args: QueryHookArgs<GenreListQuery>) => {
const { options, query, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
cacheTime: 1000 * 60 * 60 * 2,
enabled: !!server?.id,
queryFn: ({ signal }) => controller.getGenreList({ query, server, signal }),
enabled: !!server,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return controller.getGenreList({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.genres.list(server?.id || ''),
select: useCallback(
(data: RawGenreListResponse | undefined) => api.normalize.genreList(data, server),
[server],
),
staleTime: 1000 * 60 * 60,
...options,
});
@@ -1,20 +1,13 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { ndNormalize } from '/@/renderer/api/navidrome.api';
import { NDAlbum } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
AlbumListQuery,
AlbumListSort,
RawAlbumListResponse,
SortOrder,
} from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { QueryOptions } from '/@/renderer/lib/react-query';
import { AlbumListQuery, AlbumListSort, SortOrder } from '/@/renderer/api/types';
import { getServerById } from '/@/renderer/store';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: QueryOptions) => {
const server = useCurrentServer();
export const useRecentlyPlayed = (args: QueryHookArgs<Partial<AlbumListQuery>>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
const requestQuery: AlbumListQuery = {
limit: 5,
@@ -25,34 +18,19 @@ export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: Quer
};
return useQuery({
queryFn: ({ signal }) =>
api.controller.getAlbumList({
enabled: !!server?.id,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getAlbumList({
apiClientProps: {
server,
signal,
},
query: requestQuery,
server,
signal,
}),
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
select: useCallback(
(data: RawAlbumListResponse | undefined) => {
let albums;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
break;
case 'subsonic':
break;
}
});
},
return {
items: albums,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
},
[server],
),
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
...options,
});
};
+65 -136
View File
@@ -1,191 +1,122 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { Box, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { TextTitle, FeatureCarousel, GridCarousel, NativeScrollArea } from '/@/renderer/components';
import { FeatureCarousel, NativeScrollArea } from '/@/renderer/components';
import { useAlbumList } from '/@/renderer/features/albums';
import { useRecentlyPlayed } from '/@/renderer/features/home/queries/recently-played-query';
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { useCurrentServer, useWindowSettings } from '/@/renderer/store';
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import { Platform } from '/@/renderer/types';
const HomeRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const server = useCurrentServer();
const cq = useContainerQuery();
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const itemsPerPage = 25;
const { windowBarStyle } = useWindowSettings();
const [pagination, setPagination] = useSetState({
mostPlayed: 0,
random: 0,
recentlyAdded: 0,
recentlyPlayed: 0,
});
const feature = useAlbumList(
{
const feature = useAlbumList({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 20,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
{
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
serverId: server?.id,
});
const featureItemsWithImage = useMemo(() => {
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
}, [feature.data?.items]);
const random = useAlbumList(
{
const random = useAlbumList({
options: {
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: pagination.random * itemsPerPage,
startIndex: 0,
},
{
cacheTime: 1000 * 60,
keepPreviousData: true,
staleTime: 1000 * 60,
},
);
serverId: server?.id,
});
const recentlyPlayed = useRecentlyPlayed(
{
const recentlyPlayed = useRecentlyPlayed({
options: {
staleTime: 0,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.DESC,
startIndex: pagination.recentlyPlayed * itemsPerPage,
startIndex: 0,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
serverId: server?.id,
});
const recentlyAdded = useAlbumList(
{
const recentlyAdded = useAlbumList({
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.DESC,
startIndex: pagination.recentlyAdded * itemsPerPage,
startIndex: 0,
},
{
keepPreviousData: true,
staleTime: 1000 * 60,
},
);
serverId: server?.id,
});
const mostPlayed = useAlbumList(
{
const mostPlayed = useAlbumList({
options: {
staleTime: 1000 * 60 * 5,
},
query: {
limit: itemsPerPage,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: pagination.mostPlayed * itemsPerPage,
startIndex: 0,
},
{
keepPreviousData: true,
staleTime: 1000 * 60 * 60,
},
);
const handleNextPage = useCallback(
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] + 1,
});
},
[pagination, setPagination],
);
const handlePreviousPage = useCallback(
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] - 1,
});
},
[pagination, setPagination],
);
serverId: server?.id,
});
const carousels = [
{
data: random?.data?.items,
loading: random?.isLoading || random.isFetching,
pagination: {
handleNextPage: () => handleNextPage('random'),
handlePreviousPage: () => handlePreviousPage('random'),
hasPreviousPage: pagination.random > 0,
itemsPerPage,
},
title: (
<TextTitle
order={2}
weight={700}
>
Explore from your library
</TextTitle>
),
loading: random?.isLoading,
title: 'Explore from your library',
uniqueId: 'random',
},
{
data: recentlyPlayed?.data?.items,
loading: recentlyPlayed?.isLoading || recentlyPlayed.isFetching,
loading: recentlyPlayed?.isLoading,
pagination: {
handleNextPage: () => handleNextPage('recentlyPlayed'),
handlePreviousPage: () => handlePreviousPage('recentlyPlayed'),
hasPreviousPage: pagination.recentlyPlayed > 0,
itemsPerPage,
},
title: (
<TextTitle
order={2}
weight={700}
>
Recently played
</TextTitle>
),
title: 'Recently played',
uniqueId: 'recentlyPlayed',
},
{
data: recentlyAdded?.data?.items,
loading: recentlyAdded?.isLoading || recentlyAdded.isFetching,
loading: recentlyAdded?.isLoading,
pagination: {
handleNextPage: () => handleNextPage('recentlyAdded'),
handlePreviousPage: () => handlePreviousPage('recentlyAdded'),
hasPreviousPage: pagination.recentlyAdded > 0,
itemsPerPage,
},
title: (
<TextTitle
order={2}
weight={700}
>
Newly added releases
</TextTitle>
),
title: 'Newly added releases',
uniqueId: 'recentlyAdded',
},
{
data: mostPlayed?.data?.items,
loading: mostPlayed?.isLoading || mostPlayed.isFetching,
loading: mostPlayed?.isLoading,
pagination: {
handleNextPage: () => handleNextPage('mostPlayed'),
handlePreviousPage: () => handlePreviousPage('mostPlayed'),
hasPreviousPage: pagination.mostPlayed > 0,
itemsPerPage,
},
title: (
<TextTitle
order={2}
weight={700}
>
Most played
</TextTitle>
),
title: 'Most played',
uniqueId: 'mostPlayed',
},
];
@@ -206,14 +137,11 @@ const HomeRoute = () => {
>
<Box
ref={cq.ref}
pt="3rem"
mb="5rem"
pt={windowBarStyle === Platform.WEB ? '5rem' : '3rem'}
px="2rem"
sx={{
height: '100%',
width: '100%',
}}
>
<Stack spacing={35}>
<Stack spacing="lg">
<FeatureCarousel data={featureItemsWithImage} />
{carousels
.filter((carousel) => {
@@ -226,9 +154,9 @@ const HomeRoute = () => {
return carousel;
})
.map((carousel, index) => (
<GridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
.map((carousel) => (
<SwiperGridCarousel
key={`carousel-${carousel.uniqueId}`}
cardRows={[
{
property: 'name',
@@ -246,15 +174,16 @@ const HomeRoute = () => {
},
},
]}
containerWidth={cq.width}
data={carousel.data}
isLoading={carousel.loading}
itemType={LibraryItem.ALBUM}
loading={carousel.loading}
pagination={carousel.pagination}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
title={{ label: carousel.title }}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
/>
))}
</Stack>
</Box>
@@ -1,7 +1,7 @@
import type { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core';
import { Button, Popover, TableConfigDropdown } from '/@/renderer/components';
import { Button, Popover } from '/@/renderer/components';
import isElectron from 'is-electron';
import {
RiArrowDownLine,
@@ -16,6 +16,7 @@ import { usePlayerControls, useQueueControls } from '/@/renderer/store';
import { PlaybackType, TableType } from '/@/renderer/types';
import { usePlayerType } from '/@/renderer/store/settings.store';
import { useSetCurrentTime } from '../../../store/player.store';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
@@ -8,7 +8,6 @@ import type {
} from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import { VirtualGridAutoSizerContainer, getColumnDefs } from '/@/renderer/components';
import {
useAppStoreActions,
useCurrentSong,
@@ -27,12 +26,13 @@ import { useMergedRef } from '@mantine/hooks';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import { ErrorBoundary } from 'react-error-boundary';
import { VirtualTable } from '/@/renderer/components/virtual-table';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { ErrorFallback } from '/@/renderer/features/action-required';
import { PlaybackType, TableType } from '/@/renderer/types';
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const utils = isElectron() ? window.electron.utils : null;
@@ -4,9 +4,10 @@ import { Stack } from '@mantine/core';
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import { PlayQueueListControls } from './play-queue-list-controls';
import { Song } from '/@/renderer/api/types';
import { PageHeader, Paper, VirtualGridContainer } from '/@/renderer/components';
import { PageHeader, Paper } from '/@/renderer/components';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
export const SidebarPlayQueue = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
@@ -4,8 +4,9 @@ import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/no
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
import type { Song } from '/@/renderer/api/types';
import { AnimatedPage } from '/@/renderer/features/shared';
import { Paper, VirtualGridContainer } from '/@/renderer/components';
import { Paper } from '/@/renderer/components';
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
const NowPlayingRoute = () => {
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react';
import { useHotkeys } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query';
import formatDuration from 'format-duration';
import isElectron from 'is-electron';
import { IoIosPause } from 'react-icons/io';
import {
RiMenuAddFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
@@ -11,6 +14,7 @@ import {
RiSkipBackFill,
RiSkipForwardFill,
RiSpeedFill,
RiStopFill,
} from 'react-icons/ri';
import styled from 'styled-components';
import { Text } from '/@/renderer/components';
@@ -25,9 +29,15 @@ import {
useShuffleStatus,
useCurrentTime,
} from '/@/renderer/store';
import { usePlayerType, useSettingsStore } from '/@/renderer/store/settings.store';
import {
useHotkeySettings,
usePlayerType,
useSettingsStore,
} from '/@/renderer/store/settings.store';
import { PlayerStatus, PlaybackType, PlayerShuffle, PlayerRepeat } from '/@/renderer/types';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { openShuffleAllModal } from './shuffle-all-modal';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
interface CenterControlsProps {
playersRef: any;
@@ -66,6 +76,7 @@ const SliderWrapper = styled.div`
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const queryClient = useQueryClient();
const [isSeeking, setIsSeeking] = useState(false);
const currentSong = useCurrentSong();
const songDuration = currentSong?.duration;
@@ -78,6 +89,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const setCurrentTime = useSetCurrentTime();
const repeat = useRepeatStatus();
const shuffle = useShuffleStatus();
const { bindings } = useHotkeySettings();
const {
handleNextTrack,
@@ -88,7 +100,11 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
handleSkipForward,
handleToggleRepeat,
handleToggleShuffle,
handleStop,
handlePause,
handlePlay,
} = useCenterControls({ playersRef });
const handlePlayQueueAdd = usePlayQueueAdd();
const currentTime = useCurrentTime();
const currentPlayerRef = player === 1 ? player1 : player2;
@@ -113,10 +129,38 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const [seekValue, setSeekValue] = useState(0);
useHotkeys([
[bindings.playPause.isGlobal ? '' : bindings.playPause.hotkey, handlePlayPause],
[bindings.play.isGlobal ? '' : bindings.play.hotkey, handlePlay],
[bindings.pause.isGlobal ? '' : bindings.pause.hotkey, handlePause],
[bindings.stop.isGlobal ? '' : bindings.stop.hotkey, handleStop],
[bindings.next.isGlobal ? '' : bindings.next.hotkey, handleNextTrack],
[bindings.previous.isGlobal ? '' : bindings.previous.hotkey, handlePrevTrack],
[bindings.toggleRepeat.isGlobal ? '' : bindings.toggleRepeat.hotkey, handleToggleRepeat],
[bindings.toggleShuffle.isGlobal ? '' : bindings.toggleShuffle.hotkey, handleToggleShuffle],
[
bindings.skipBackward.isGlobal ? '' : bindings.skipBackward.hotkey,
() => handleSkipBackward(skip?.skipBackwardSeconds || 5),
],
[
bindings.skipForward.isGlobal ? '' : bindings.skipForward.hotkey,
() => handleSkipForward(skip?.skipForwardSeconds || 5),
],
]);
return (
<>
<ControlsContainer>
<ButtonsContainer>
<PlayerButton
icon={<RiStopFill size={15} />}
tooltip={{
label: 'Stop',
openDelay: 500,
}}
variant="tertiary"
onClick={handleStop}
/>
<PlayerButton
$isActive={shuffle !== PlayerShuffle.NONE}
icon={<RiShuffleFill size={15} />}
@@ -199,6 +243,21 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
variant="tertiary"
onClick={handleToggleRepeat}
/>
<PlayerButton
icon={<RiMenuAddFill size={15} />}
tooltip={{
label: 'Shuffle all',
openDelay: 500,
}}
variant="tertiary"
onClick={() =>
openShuffleAllModal({
handlePlayQueueAdd,
queryClient,
})
}
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
@@ -12,11 +12,19 @@ import { useFastAverageColor } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { PlayerData, usePlayerData, usePlayerStore } from '/@/renderer/store';
const PlayerContainer = styled(Flex)`
flex: 0.5;
gap: '1rem';
@media screen and (min-width: 1080px) {
max-width: 60%;
}
`;
const Image = styled(motion.img)`
position: absolute;
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 5px;
@@ -27,8 +35,10 @@ const ImageContainer = styled(motion.div)`
position: relative;
display: flex;
align-items: center;
height: 65%;
max-width: 100%;
aspect-ratio: 1/1;
height: 65%;
margin-bottom: 1rem;
`;
const imageVariants: Variants = {
@@ -120,13 +130,12 @@ export const FullScreenPlayerImage = () => {
}, [imageState, queue, setImageState]);
return (
<Flex
<PlayerContainer
align="center"
className="full-screen-player-image-container"
direction="column"
justify="flex-start"
p="1rem"
sx={{ flex: 0.5, gap: '1rem' }}
>
<ImageContainer>
<AnimatePresence
@@ -171,6 +180,7 @@ export const FullScreenPlayerImage = () => {
<Stack
className="full-screen-player-image-metadata"
spacing="sm"
sx={{ maxWidth: '100%' }}
>
<TextTitle
align="center"
@@ -232,6 +242,6 @@ export const FullScreenPlayerImage = () => {
{currentSong?.releaseYear && <Badge size="lg">{currentSong?.releaseYear}</Badge>}
</Group>
</Stack>
</Flex>
</PlayerContainer>
);
};
@@ -72,7 +72,10 @@ export const FullScreenPlayerQueue = () => {
position="center"
>
{headerItems.map((item) => (
<Box pos="relative">
<Box
key={`tab-${item.label}`}
pos="relative"
>
<Button
fullWidth
uppercase
@@ -5,18 +5,21 @@ import { Variants, motion } from 'framer-motion';
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
import { useLocation } from 'react-router';
import styled from 'styled-components';
import { Button, Option, Popover, Switch, TableConfigDropdown } from '/@/renderer/components';
import { Button, Option, Popover, Switch } from '/@/renderer/components';
import {
useCurrentSong,
useFullScreenPlayerStore,
useFullScreenPlayerStoreActions,
useWindowSettings,
} from '/@/renderer/store';
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
import { Platform } from '/@/renderer/types';
const Container = styled(motion.div)`
z-index: 100;
z-index: 200;
display: flex;
justify-content: center;
padding: 2rem;
@@ -123,22 +126,25 @@ const Controls = () => {
};
const containerVariants: Variants = {
closed: {
height: 'calc(100vh - 90px)',
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: -100,
closed: (custom) => {
const { windowBarStyle } = custom;
return {
height: windowBarStyle !== Platform.WEB ? 'calc(100vh - 120px)' : 'calc(100vh - 90px)',
position: 'absolute',
top: '100vh',
transition: {
duration: 0.5,
ease: 'easeInOut',
},
width: '100vw',
y: -100,
};
},
open: (custom) => {
const { dynamicBackground, background } = custom;
const { dynamicBackground, background, windowBarStyle } = custom;
return {
background: dynamicBackground ? background : 'var(--main-bg)',
height: 'calc(100vh - 90px)',
height: windowBarStyle !== Platform.WEB ? 'calc(100vh - 120px)' : 'calc(100vh - 90px)',
left: 0,
position: 'absolute',
top: 0,
@@ -160,6 +166,7 @@ const containerVariants: Variants = {
export const FullScreenPlayer = () => {
const { dynamicBackground } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions();
const { windowBarStyle } = useWindowSettings();
const location = useLocation();
const isOpenedRef = useRef<boolean | null>(null);
@@ -178,7 +185,7 @@ export const FullScreenPlayer = () => {
return (
<Container
animate="open"
custom={{ background, dynamicBackground }}
custom={{ background, dynamicBackground, windowBarStyle }}
exit="closed"
initial="closed"
variants={containerVariants}
@@ -1,17 +1,19 @@
import React, { MouseEvent } from 'react';
import { Center, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks';
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
import { generatePath, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
import { Button, Text, Tooltip } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
import {
useAppStoreActions,
useAppStore,
useCurrentSong,
useSetFullScreenPlayerStore,
useFullScreenPlayerStore,
useSidebarStore,
useHotkeySettings,
} from '/@/renderer/store';
import { fadeIn } from '/@/renderer/styles';
import { LibraryItem } from '/@/renderer/api/types';
@@ -26,6 +28,7 @@ const LeftControlsContainer = styled.div`
`;
const ImageWrapper = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
@@ -42,9 +45,11 @@ const MetadataStack = styled(motion.div)`
`;
const Image = styled(motion.div)`
position: relative;
width: 60px;
height: 60px;
background-color: var(--placeholder-bg);
cursor: pointer;
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
${fadeIn};
@@ -84,10 +89,13 @@ export const LeftControls = () => {
const { setSideBar } = useAppStoreActions();
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
const hideImage = useAppStore((state) => state.sidebar.image);
// const hideImage = useAppStore((state) => state.sidebar.image);
const { image, collapsed } = useSidebarStore();
const hideImage = image && !collapsed;
const currentSong = useCurrentSong();
const title = currentSong?.name;
const artists = currentSong?.artists;
const { bindings } = useHotkeySettings();
const isSongDefined = Boolean(currentSong?.id);
@@ -96,16 +104,23 @@ export const LeftControls = () => {
SONG_CONTEXT_MENU_ITEMS,
);
const handleToggleFullScreenPlayer = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const handleToggleFullScreenPlayer = (e?: MouseEvent<HTMLDivElement> | KeyboardEvent) => {
e?.stopPropagation();
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
};
const handleToggleSidebarImage = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const handleToggleSidebarImage = (e?: MouseEvent<HTMLButtonElement>) => {
e?.stopPropagation();
setSideBar({ image: true });
};
useHotkeys([
[
bindings.toggleFullscreenPlayer.allowGlobal ? '' : bindings.toggleFullscreenPlayer.hotkey,
handleToggleFullScreenPlayer,
],
]);
return (
<LeftControlsContainer>
<LayoutGroup>
@@ -124,13 +139,16 @@ export const LeftControls = () => {
transition={{ duration: 0.3, ease: 'easeInOut' }}
onClick={handleToggleFullScreenPlayer}
>
{currentSong?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={currentSong?.imageUrl}
/>
) : (
<>
<Tooltip
label="Open fullscreen player"
openDelay={500}
>
{currentSong?.imageUrl ? (
<PlayerbarImage
loading="eager"
src={currentSong?.imageUrl}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
@@ -142,24 +160,26 @@ export const LeftControls = () => {
size={50}
/>
</Center>
</>
)}
)}
</Tooltip>
<Button
compact
opacity={0.8}
radius={50}
size="md"
sx={{ position: 'absolute', right: 2, top: 2 }}
tooltip={{ label: 'Expand', openDelay: 500 }}
variant="default"
onClick={handleToggleSidebarImage}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
{!collapsed && (
<Button
compact
opacity={0.8}
radius={50}
size="md"
sx={{ cursor: 'default', position: 'absolute', right: 2, top: 2 }}
tooltip={{ label: 'Expand', openDelay: 500 }}
variant="default"
onClick={handleToggleSidebarImage}
>
<RiArrowUpSLine
color="white"
size={20}
/>
</Button>
)}
</Image>
</ImageWrapper>
)}
@@ -1,5 +1,5 @@
/* stylelint-disable no-descending-specificity */
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react';
import type { TooltipProps, UnstyledButtonProps } from '@mantine/core';
import { UnstyledButton } from '@mantine/core';
import { motion } from 'framer-motion';
@@ -118,33 +118,41 @@ const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
: ButtonTertiaryVariant};
`;
export const PlayerButton = ({ tooltip, variant, icon, ...rest }: PlayerButtonProps) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper variant={variant}>
<StyledPlayerButton
export const PlayerButton = forwardRef<HTMLDivElement, PlayerButtonProps>(
({ tooltip, variant, icon, ...rest }: PlayerButtonProps, ref) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper
ref={ref}
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
return (
<MotionWrapper variant={variant}>
<StyledPlayerButton
return (
<MotionWrapper
ref={ref}
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
};
<StyledPlayerButton
variant={variant}
{...rest}
>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
},
);
PlayerButton.defaultProps = {
$isActive: false,

Some files were not shown because too many files have changed in this diff Show More