Compare commits

...

297 Commits

Author SHA1 Message Date
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
jeffvli a614a6ff9c Bump electron builder 2023-04-03 05:31:37 -07:00
jeffvli 08a1cb1ae9 Update lockfiles 2023-04-03 04:38:02 -07:00
jeffvli 75e93b8ea2 Fix linux build 2 2023-04-03 04:30:31 -07:00
jeffvli 5d28abae91 Fix linux build 2023-04-03 04:26:47 -07:00
jeffvli c483cdb871 Add optional chain to mpvPlayer to fix web 2023-04-03 04:26:00 -07:00
jeffvli fe14ca25c6 Remove mpris-service from renderer deps 2023-04-03 04:15:20 -07:00
jeffvli 0965efab21 Increase default sidebar width 2023-04-03 04:13:56 -07:00
jeffvli 598b627bb4 Supress errors from main process 2023-04-03 04:13:41 -07:00
jeffvli b8614495f6 Bump to 0.0.1-alpha6 2023-04-03 03:53:30 -07:00
jeffvli e02643123c Fix various mpv setting options 2023-04-03 03:53:30 -07:00
jeffvli 5d8cad06d7 Fix filters on album list detail 2023-04-03 03:53:30 -07:00
jeffvli 2b8a97b8c2 Remove modal overflow 2023-04-03 03:45:08 -07:00
jeffvli 44cd1b33bf Adjust light theme 2023-04-03 03:44:13 -07:00
jeffvli 90b503906f Set grid view to use local state 2023-04-03 03:42:51 -07:00
jeffvli 77bfb916ba MPV player enhancements
- start the player from the renderer
- dynamically modify settings without restart
2023-04-02 21:41:32 -07:00
jeffvli f35152a169 Add hotkey settings tab 2023-03-31 07:26:10 -07:00
jeffvli 0d9224bc09 Style fixes 2023-03-31 06:22:04 -07:00
jeffvli cf4f80c82b Split linux build to ubuntu runner (#57) 2023-03-31 06:15:04 -07:00
jeffvli fa717b9bca Downgrade electron 23 -> 22
- To support windows native deps (nodert)
2023-03-31 06:15:04 -07:00
jeffvli 8e2dce34f0 Change default card display to POSTER 2023-03-31 06:15:04 -07:00
jeffvli dce6fc7e7d Fix macOS window bar color 2023-03-31 06:15:04 -07:00
jeffvli 781e3c3c4d Add app version to settings page 2023-03-31 06:15:04 -07:00
jeffvli 0d4d5b5de0 Add reset to default for settings 2023-03-31 06:15:04 -07:00
jeffvli 293d8ec584 Add setting to disable auto update 2023-03-31 06:15:04 -07:00
jeffvli 6ccef6e515 Prevent auto checking of audio devices 2023-03-31 06:15:04 -07:00
jeffvli 5f7b005626 Refactor layout components 2023-03-31 06:14:59 -07:00
jeffvli b590636303 Fix invalid import 2023-03-31 05:06:54 -07:00
jeffvli a17e0adf44 Prevent header play button from being squished 2023-03-30 08:22:40 -07:00
jeffvli 2b1c1d5e59 Add tray settings (#49) 2023-03-30 08:09:20 -07:00
jeffvli eecbcddea3 Refactor settings store and components 2023-03-30 06:44:33 -07:00
jeffvli 373441e4c6 Adjust shadow on playerbar image 2023-03-30 05:02:58 -07:00
jeffvli dbcda5b634 Fix grid layout for web window bar 2023-03-30 04:57:51 -07:00
jeffvli bc5f1f13f0 Move settings to route instead of modal 2023-03-30 03:01:31 -07:00
jeffvli 0c13b09029 Fix window controls when sidebar queue enabled (#36) 2023-03-29 20:39:59 -07:00
jeffvli 4ffc544e87 Remove unused preload items 2023-03-29 20:38:37 -07:00
jeffvli cf00992d71 Fix song repeating when disabled (#55) 2023-03-29 18:17:56 -07:00
jeffvli 3848e9840d Add additional fix to song list header play button (#28) 2023-03-29 15:07:43 -07:00
jeffvli 930bbb33fd Rename titlebar to windowbar 2023-03-29 14:54:10 -07:00
jeffvli 4332a9ea3a Improve sidebar playlist resize performance 2023-03-29 14:27:25 -07:00
jeffvli ccfe0bfd9d Prevent titlebar drag when using windowbar 2023-03-29 01:19:02 -07:00
jeffvli f5fc56eee1 Remove boilerplate issue templates 2023-03-29 00:52:27 -07:00
jeffvli cd6bf25011 Prevent second app instance (#62) 2023-03-29 00:40:29 -07:00
jeffvli 335287d119 Decrease size of play button 2023-03-29 00:31:32 -07:00
jeffvli 50af8f4d3a Split sidebar action bar to separate component 2023-03-29 00:31:09 -07:00
jeffvli 58c7370536 Add dedicated OS window bars (#22) 2023-03-28 23:59:51 -07:00
jeffvli ececc394e2 Fix filled button styles 2023-03-28 16:13:18 -07:00
jeffvli 219a9ed613 Change grid size to items per row 2023-03-28 15:37:50 -07:00
Jeff e47fcfc62e Add fullscreen player view (#27)
* Add store controls for fullscreen player

* Normalize styles for playback config

* Add fullscreen player component

* Add option component

* Update player controls to use option/popover components

* Add esc hotkey to close player

* Add usePlayerData hook
2023-03-28 14:19:23 -07:00
ssnarf 6cfdb8ff84 Fixes #51. Update titleCombined datatype. (#59) 2023-03-28 14:15:51 -07:00
jeffvli ef4cdfa028 Set artist links to use outline button 2023-03-09 18:16:57 -08:00
jeffvli 1eed26abab Set genres to use outline button 2023-03-09 18:14:40 -08:00
jeffvli a2851dd700 Use generic for play button 2023-03-09 18:10:27 -08:00
jeffvli 563db1138e Fix list store for artist detail 2023-03-09 18:09:59 -08:00
jeffvli 84587da701 Add additional vars to base components 2023-03-09 18:08:15 -08:00
jeffvli f0a836fc1f Fix loading skeleton for poster card 2023-03-09 16:37:54 -08:00
jeffvli 5539e2cd4e Adjust playerbar background 2023-03-09 13:41:59 -08:00
jeffvli 30b013dfa5 Decrease gap between grid items 2023-03-09 13:41:41 -08:00
jeffvli 8343f4f80b Fix typo on mpv params placeholder 2023-03-09 13:34:39 -08:00
jeffvli e8dcba0456 Add pointer-events to grid card components
- Prevent delay on hover event
2023-03-09 13:23:36 -08:00
jeffvli 27cbc23d87 Set default mpv gapless-audio config to weak (#45) 2023-03-09 12:51:30 -08:00
jeffvli 275d68ec5b Fix mpv stopping after first playback
- On startup, the first time a song is played, mpv will stop after playback
- This adds a loop to the queue handler to automatically retry when failing to add to the queue
2023-03-09 12:45:13 -08:00
jeffvli 7f9de4b180 Fix transition props 2023-03-09 10:59:29 -08:00
jeffvli 231f10cbe2 Allow adding server without password (#48) 2023-03-09 10:45:44 -08:00
jeffvli b4664f45b4 Adjust default grid sizing and handler 2023-03-09 02:36:23 -08:00
jeffvli 3153cdd6c4 Auto scale grid items (#30) 2023-03-09 02:26:09 -08:00
jeffvli 69292a083d Fix web volume handler (#35) 2023-03-09 01:40:08 -08:00
jeffvli 123478a24f Normalize album artist list store 2023-03-05 21:02:05 -08:00
jeffvli 828cca9c19 Fix playlist pagination 2023-03-05 19:31:28 -08:00
jeffvli f7740407c3 Migrate transition props 2023-03-05 18:49:38 -08:00
jeffvli 157ac9f3a2 Keep playlist store separate 2023-03-05 18:47:24 -08:00
jeffvli f21c9010ac Darken default playerbar 2023-03-05 18:38:22 -08:00
jeffvli 7c045e5b23 Bump to mantine 6 stable 2023-03-05 18:38:22 -08:00
jeffvli ae292e3a5f Begin normalizing list stores 2023-03-05 18:38:22 -08:00
jeffvli 918b77eebb Adjust default dropdown styling 2023-03-05 18:38:22 -08:00
Adam 661751f306 Fix playback being interrupted by clicking maximize. #39 (#42) 2023-03-03 18:23:59 -08:00
jeffvli 2260caba00 Fix preview URLs 2023-02-28 03:02:32 -08:00
jeffvli 3fe0873dc1 Add preview images to README 2023-02-28 03:01:45 -08:00
jeffvli 7c6ec73617 Update README to add features and remove deprecated information 2023-02-28 02:45:18 -08:00
jeffvli 76dcd1c28e Bump to mantine v6 alpha 7 2023-02-27 19:39:29 -08:00
jeffvli 4fb1f4d2cb Bump to electron v23 2023-02-27 19:12:03 -08:00
jeffvli 92039b95c3 Fix types on top song request 2023-02-27 12:44:25 -08:00
jeffvli c0a703be7a Add top song list for jellyfin 2023-02-27 12:17:22 -08:00
jeffvli f08538cbfb Remove electronmon from default 2023-02-25 19:02:02 -08:00
jeffvli 1fa975ccec Clean up unused wrapper component 2023-02-25 19:01:42 -08:00
jeffvli ac62c26099 Fix type 2023-02-25 18:31:51 -08:00
Jeff 7ae3d9d99a Fix list view breaking on undefined rating value (#32) 2023-02-25 16:35:19 -08:00
Jeff a9cfcaeda6 Fix artist song list play behavior (#29) 2023-02-22 12:22:39 -08:00
jeffvli 3d8b25922e Fix date picker props 2023-02-11 00:21:39 -08:00
jeffvli a9089859ce Fix radius for last item in context menu 2023-02-11 00:21:39 -08:00
Jeff c878e36015 Ignore CORS & SSL (#23)
* Add toggle to ignore CORS

* Add option to ignore SSL
2023-02-10 11:53:26 -08:00
jeffvli 8eec6b6b8a Bump to version 0.0.1-alpha5 2023-02-09 00:37:28 -08:00
jeffvli 60219c2522 Minor player adjustments 2023-02-09 00:36:55 -08:00
jeffvli cdb5cdf442 Fix sizing of drawer queue, add border 2023-02-09 00:18:25 -08:00
Jeff 23f84d68e8 Add MPRIS support (#25)
* Stop mpv on app close for linux/macOS (#20)

* Add initial MPRIS support

* Fix mpv path check
2023-02-08 23:57:06 -08:00
jeffvli 0f7f4b969f Fix drawer border radius 2023-02-08 17:00:07 -08:00
jeffvli 563a4b3a7c Add button to open browser devtools 2023-02-08 14:42:13 -08:00
jeffvli 4700772e6d Add padding for dropdown label 2023-02-08 14:39:59 -08:00
jeffvli ffb7f915c3 Add context menu to queue items 2023-02-08 11:46:39 -08:00
jeffvli 17d5ef1f6b Use flex instead of grid for context menu item 2023-02-08 11:46:02 -08:00
jeffvli 9dcc42ff28 Add border radius for all dropdown items (#22) 2023-02-08 11:45:29 -08:00
jeffvli 2845476d83 Migrate sidebar playlist to react-window 2023-02-08 03:44:37 -08:00
jeffvli 147b155d60 Add hook for hideable scrollbar 2023-02-08 03:44:05 -08:00
jeffvli 8b5e463546 Remove tanstack/react-virtual package 2023-02-08 03:43:18 -08:00
jeffvli 822dcd8429 Fix error on paginated table persistence 2023-02-08 10:05:10 -08:00
Jeff 9f2e873366 Redesign sidebar / header and other misc. improvements (#24)
* Remove 1920px max width

* Fix position of list controls menu

* Match size and color of search input

* Adjust library header sizing

* Move app menu to sidebar

* Increase row buffer on play queue list

* Fix query builder styles

* Fix playerbar slider track bg

* Adjust titlebar styles

* Fix invalid modal prop

* Various adjustments to detail pages

* Fix sidebar height calculation

* Fix list null indicators, add filter indicator

* Adjust playqueue styles

* Fix jellyfin releaseYear normalization

* Suppress browser context menu on ag-grid

* Add radius to drawer queue -- normalize layout

* Add modal styles to provider theme

* Fix playlist song list pagination

* Add disc number to albums with more than one disc

* Fix query builder boolean values

* Adjust input placeholder color

* Properly handle rating/favorite from context menu on table

* Conform dropdown menu styles to context menu

* Increase sort type select width

* Fix drawer queue radius

* Change primary color

* Prevent volume wheel from invalid values

* Add icons to query builder dropdowns

* Update notification styles

* Update scrollbar thumb styles

* Remove "add to playlist" on smart playlists

* Fix "add to playlist" from context menu
2023-02-07 22:47:23 -08:00
jeffvli d2c0d4c11f Fix modal styles for mantine v6 2023-02-06 02:21:11 -08:00
jeffvli 48b6e8bf93 Remove box shadow from filter header 2023-02-06 02:17:47 -08:00
jeffvli 17a6b37545 Fix active tab color 2023-02-06 02:17:25 -08:00
jeffvli eedcef8f52 Remove custom image component for grid images 2023-02-06 01:57:49 -08:00
jeffvli 757eddd6f1 Fix disabled input styles for mantine v6 2023-02-06 01:52:07 -08:00
jeffvli 13f48711a9 Use local seekvalue to smooth out slider drag 2023-02-06 01:45:56 -08:00
jeffvli 1bbdf09dcc Add padding to playlist list items 2023-02-05 23:18:42 -08:00
jeffvli 3b7c6ce25e Set transparent window control background 2023-02-05 23:13:44 -08:00
jeffvli 38118e74ae Update to new list header style 2023-02-05 22:41:47 -08:00
jeffvli 6872a7e8b2 Adjust various base components 2023-02-05 20:52:25 -08:00
jeffvli ab3236230b Use virtualized list on sidebar playlists 2023-02-05 18:59:39 -08:00
jeffvli 6ef88e56ec Adjust scrollarea to add styles and omit header 2023-02-05 18:02:27 -08:00
jeffvli 18c18ea322 Bump packages 2023-02-05 17:59:37 -08:00
Jeff 22fec8f9d3 Add ratings support (#21)
* Update rating types for multiserver support

* Add rating mutation

* Add rating support to table views

* Add rating support on playerbar

* Add hovercard component

* Handle rating from context menu

- Improve context menu components
- Allow left / right icons
- Allow nested menus

* Add selected item count

* Fix context menu auto direction

* Add transition and move portal for context menu

* Re-use context menu for all item dropdowns

* Add ratings to detail pages / double click to clear

* Bump react-query package
2023-02-05 05:19:01 -08:00
jeffvli f50ec5cf31 Fix error boundary styles 2023-01-30 21:34:56 -08:00
jeffvli 4cbc28a087 Add volume wheel scroll & new slider component 2023-01-30 21:34:27 -08:00
jeffvli 01fdd25406 Remove react-slider dependency 2023-01-30 21:28:37 -08:00
jeffvli 320f583660 Fix misc. styles 2023-01-30 20:16:43 -08:00
jeffvli 8cc5ec6797 Fix workflow name for PR binary comment 2023-01-30 20:03:18 -08:00
jeffvli edfbc2538d Set PR publish to upload binaries separately by OS 2023-01-30 20:03:08 -08:00
Jeff 484c96187c Add scrobble functionality (#19)
* Fix slider bar background to use theme

* Add "scrobbleAtDuration" to settings store

* Add subscribeWithSelector and playCount incrementor

* Add scrobbling API and mutation

* Add scrobble settings

* Begin support for multi-server queue handling

* Dynamically set version on auth header

* Add scrobbling functionality for navidrome/jellyfin
2023-01-30 20:01:57 -08:00
jeffvli 85bf910d65 Add additional controls to playerbar 2023-01-30 02:39:25 -08:00
jeffvli 5ddd0872ef Adjust various styles 2023-01-30 01:36:36 -08:00
jeffvli 2700774469 Fix player button styles 2023-01-30 01:05:23 -08:00
jeffvli c79a992829 Increase text size of table cells 2023-01-30 01:05:02 -08:00
jeffvli 5e693313d8 Adjust context menu styles 2023-01-30 01:04:38 -08:00
Jeff 59f4f43e84 Add ability to add/remove songs from playlist (#17)
* Add api for add/remove playlist items

* Add playlistItemId property to normalized Song

- This is used for Navidrome to delete songs from playlists

* Add mutations for add/remove from playlist

* Add context modal for playlist add

* Add remove from playlist from context menu

* Set jellyfin to use playlistItemId

* Adjust font sizing

* Add playlist add from detail pages

* Bump mantine to v6-alpha.2
2023-01-29 18:40:26 -08:00
jeffvli be39c2bc1f Add pr package script 2023-01-29 18:27:14 -08:00
jeffvli b49ba2d04c Fix pr build 2023-01-29 18:10:39 -08:00
jeffvli d7c87efe10 Add PR build workflows 2023-01-28 21:06:41 -08:00
Jeff 44a4b88809 Migrate to mantine v6 (#15)
* Add letter spacing to cell text

* Set window control height in px

* Add temp unused routes

* Migrate text title font weights

* Bump mantine to v6 alpha

* Migrate modals / notifications

* Increase header bar to 65px

* Adjust play button props

* Migrate various components

* Migrate various pages and root styles

* Adjust default badge padding

* Fix sidebar spacing

* Fix list header badges

* Adjust default theme
2023-01-28 20:46:07 -08:00
jeffvli 768269f074 Bump to v0.0.1-alpha4 2023-01-15 22:22:22 -08:00
jeffvli d23ae2a8db Replace default navidrome artist image placeholder path 2023-01-15 22:08:50 -08:00
jeffvli 88591697a2 Set route button to uppercase for consistency 2023-01-15 22:01:56 -08:00
jeffvli dcbb00f7c4 Remove filters button from album artist list 2023-01-15 21:58:50 -08:00
jeffvli e063ee0c29 Add smart playlist builder to create playlist form 2023-01-15 21:58:25 -08:00
jeffvli e5f478218e Forward playlist query filters 2023-01-15 21:57:44 -08:00
jeffvli 9a809a61dd Fix query not setting properly on render after save 2023-01-15 21:20:11 -08:00
jeffvli 4058ab7491 Change default smart playlist sort to album asc 2023-01-15 21:15:35 -08:00
jeffvli 48ccebd4c2 Clean up persisted list state when switching servers 2023-01-15 21:10:06 -08:00
jeffvli fcf00b9de1 Use native img on table images 2023-01-15 21:03:24 -08:00
jeffvli f7919b296b Change modal bg color 2023-01-15 20:48:34 -08:00
jeffvli 0b6af1fd21 Add additional padding to context menu items 2023-01-15 20:46:45 -08:00
jeffvli 3f424b72f6 Remove scroll persistence when viewing artist songs 2023-01-15 20:41:58 -08:00
jeffvli fc9d4616ba Adjust max height for settings modal 2023-01-15 20:40:23 -08:00
jeffvli 2e74f7533a Reuse song list for artist songs 2023-01-15 20:39:43 -08:00
jeffvli 784da2f8b9 Adjust context menu styles 2023-01-15 16:38:38 -08:00
jeffvli dbc29568ca Fix table params 2023-01-15 16:34:30 -08:00
jeffvli 5614ad54f2 Add view artist discography 2023-01-15 16:22:07 -08:00
jeffvli 67523f1e7b Adjust spacing between sections 2023-01-15 16:16:13 -08:00
jeffvli fc57605219 Select router type based on desktop/web 2023-01-15 16:11:26 -08:00
jeffvli a31a2ffdbf Increase page transition animation speed from 0.8 -> 0.5 2023-01-15 16:10:00 -08:00
jeffvli 900d47d6f9 Fix various types 2023-01-15 16:07:02 -08:00
jeffvli 6bdf0736ec Add genres to album detail page 2023-01-13 14:07:57 -08:00
jeffvli 92cde507d9 Add artist top songs list 2023-01-13 13:51:19 -08:00
jeffvli 8afd626806 Adjust filters 2023-01-13 01:54:35 -08:00
jeffvli 53f3758d2a Set static width for drawer queue 2023-01-13 01:53:34 -08:00
jeffvli 1e6eb33408 Link to album artist page instead of artist 2023-01-13 01:50:48 -08:00
jeffvli 51e20a81b7 Add artist song list page 2023-01-13 01:44:47 -08:00
jeffvli 4e8dc868bb Add routes to artist song list / discography 2023-01-13 01:09:34 -08:00
jeffvli 9b8bcb05bd Add initial album artist detail route 2023-01-12 18:43:25 -08:00
jeffvli 55e2a9bf37 Fix delimiter color 2023-01-12 18:41:41 -08:00
jeffvli a82b087969 Add itemtype and optional pagination for carousel 2023-01-12 13:31:25 -08:00
jeffvli 45aef104fe Update album artist base route 2023-01-12 12:45:44 -08:00
jeffvli 6746903808 Increase white ignore threshold 2023-01-12 12:28:19 -08:00
jeffvli 36c1f4e736 Set text children to optional 2023-01-12 00:44:33 -08:00
jeffvli b0ca7ab127 Use updated cardrows component 2023-01-12 00:43:39 -08:00
jeffvli 2026bc8f48 Add react-virtual package 2023-01-08 21:11:47 -08:00
jeffvli 6da8663a1d Fix card row array id assignment 2023-01-08 20:55:36 -08:00
jeffvli b4e9f48667 Add prop to force transparent grid header 2023-01-08 20:47:05 -08:00
jeffvli d58ba92cbd Add customizable scrollbar offset on scrollarea 2023-01-08 20:46:20 -08:00
jeffvli c51194cd03 Update album artist detail endpoints 2023-01-08 20:45:38 -08:00
jeffvli ba0ec909c8 Fix list header wrap on item count 2023-01-08 02:47:38 -08:00
jeffvli 0db1c36d86 Fix ND recently added sort 2023-01-08 02:39:35 -08:00
jeffvli 3e9fb521f0 Adjust sticky header to align with sidebar 2023-01-08 02:18:05 -08:00
jeffvli b5ad30a9bc Fix header being squished by table 2023-01-08 02:13:39 -08:00
jeffvli 5344493845 Fix mis-imported components 2023-01-08 02:08:19 -08:00
jeffvli 6f2108940e Adjust fixed table header 2023-01-08 02:04:07 -08:00
jeffvli 4f3e732891 Fix positioning of page header 2023-01-08 02:01:12 -08:00
jeffvli 0e2678575a Add favorite functionality to album detail 2023-01-08 01:45:56 -08:00
jeffvli d6035a5f97 Transform default number field value
- Navidrome allows for "string" number query values which will not work with the numberinput
2023-01-08 01:07:17 -08:00
jeffvli b82a5eda78 Fix undefined type for favorites 2023-01-08 00:53:30 -08:00
jeffvli d17f30f5e6 Add favorite handler to grid cards 2023-01-08 00:52:53 -08:00
jeffvli 7a3bdb531d Readd missing scroll restoration 2023-01-08 00:08:26 -08:00
jeffvli c6f3b49a6e Increase width of duration cell 2023-01-07 23:45:40 -08:00
jeffvli 99cd48ca6d Adjust color and size of favorite cell 2023-01-07 23:44:53 -08:00
jeffvli 02ed9b7a5c Move ag-grid header margin to specific components 2023-01-07 23:37:33 -08:00
jeffvli 0f66687843 Misc. optimizations 2023-01-07 23:09:58 -08:00
jeffvli 586f42867d Fix lodash import 2023-01-07 18:25:36 -08:00
jeffvli 14a7f0254d Move some ag-grid styles back to theme file
- Styles were not applying properly from the global
2023-01-07 18:23:49 -08:00
jeffvli 0aa0e51daa Add favoriting from context menu 2023-01-07 18:23:10 -08:00
jeffvli f4f06abd72 Use global libraryitem type on favorite query 2023-01-07 18:21:54 -08:00
jeffvli 7d8cb0bb45 Refactor context menu handler into hook 2023-01-07 18:16:19 -08:00
jeffvli 2edffa02d0 Add favoriting from table rows 2023-01-07 16:33:14 -08:00
jeffvli cfa4e5e45c Update favorite/rating endpoints
- Refactor subsonic api endpoints to set the default auth params
- The beforeRequest hook is unable to dynamically set existing params
2023-01-07 16:09:40 -08:00
jeffvli f879171398 Add renderer for last played date column 2023-01-07 14:30:17 -08:00
jeffvli 6bfebd2923 Adjust position of track/disc number cells 2023-01-07 04:09:32 -08:00
jeffvli 3c60f406ea Adjust ag-grid styles 2023-01-07 03:50:34 -08:00
jeffvli af1c16ee51 Adjust dropdown styling 2023-01-07 03:49:56 -08:00
jeffvli 1b25d88692 Rename add to queue text 2023-01-07 03:49:18 -08:00
jeffvli 8a48abbbc8 Additional changes to column defaults 2023-01-07 03:30:48 -08:00
jeffvli 6deab38c67 Add undefined check on scrollTo 2023-01-07 03:28:28 -08:00
jeffvli 915b0eb372 Add play handlers and item count to list pages 2023-01-07 03:28:03 -08:00
jeffvli 6bb0474d62 Change ND ALBUM_SONGS sort value 2023-01-07 03:27:13 -08:00
jeffvli 2df96c0d31 Add song filter as add queue type 2023-01-07 03:26:18 -08:00
jeffvli d94d7b5ee5 Export selector for list filters 2023-01-07 03:21:03 -08:00
jeffvli 2f7f6bead9 Remove persisted playqueue in localstorage
- This will break if there are enough songs in the queue. A new implementation will be added in the future
2023-01-07 01:42:20 -08:00
jeffvli 3bbddcf092 Move grid-specific styling out of theme styles 2023-01-06 23:52:05 -08:00
jeffvli 838c6a8b6a Adjust titlebar / window controls styling 2023-01-06 23:51:26 -08:00
jeffvli 5889b8976c Force header bg color in detail lists 2023-01-06 23:34:31 -08:00
jeffvli d06ddc9560 Update themes 2023-01-06 23:33:18 -08:00
jeffvli 9b1f4e7154 Add infinite table defaults 2023-01-06 18:24:31 -08:00
jeffvli b569ec31ae Move common table functions into base component 2023-01-06 17:59:02 -08:00
jeffvli f7b8e34905 Improve semantic html of default layout and add ids 2023-01-06 14:49:41 -08:00
jeffvli 3cf7127f56 Optimize current song image layout transition 2023-01-06 14:27:31 -08:00
jeffvli cb823d94e5 Add dynamic message for infinite scroll handler 2023-01-06 13:53:02 -08:00
jeffvli 4d0620c5df Update misc on detail pages 2023-01-06 13:50:40 -08:00
jeffvli 01371d0227 Add pageIndex on infinite query results
- Result selector runs on every page on every fetch, which means that the uniqueId is not immutable. We need a static index on each item since a playlist can have duplicate song ids
2023-01-06 13:48:29 -08:00
jeffvli 9e6a81cb62 Use rem instead of px for sticky header 2023-01-06 13:34:41 -08:00
jeffvli 7b616b44fa Add prop to deselect rows on outside click 2023-01-06 13:28:10 -08:00
jeffvli 4c275ea878 Add sticky table header to detail pages 2023-01-06 11:46:17 -08:00
jeffvli b59c86f78f Add hook to fix table header to detail header 2023-01-06 11:44:50 -08:00
jeffvli 14e6b4e7d6 Change sidebar icons 2023-01-06 10:46:09 -08:00
jeffvli 63cdefcb27 Increase white ignore threshold 2023-01-06 10:29:49 -08:00
jeffvli 11f9721abe Reduce global query cache time from 15min to 3min 2023-01-06 03:33:43 -08:00
jeffvli 0a82438beb Set 1 minute cache time on manual query fetches 2023-01-06 03:33:11 -08:00
jeffvli 8bd1cc80bc Fix rating property on card row 2023-01-06 03:32:35 -08:00
jeffvli 624b1bb94d Add tooltip delay for expand/collapse of player image 2023-01-06 01:59:37 -08:00
jeffvli cf904b5d51 Set outline color for rating component 2023-01-06 01:52:01 -08:00
jeffvli 2ab48f5c97 Set size/color of custom table headers 2023-01-06 01:51:29 -08:00
jeffvli d56799e519 Adjust sidebar style 2023-01-06 01:48:56 -08:00
jeffvli 083e219ed2 Improve average color matching
- Add threshold to ignored white/black values
- Add ignored transparency colors
2023-01-06 01:05:54 -08:00
jeffvli 1b379882f5 Reduce size of song imageUrl
- Increase efficiency of loading times for song lists
2023-01-06 01:03:58 -08:00
jeffvli ab031820f6 Add favorite/rating table columns 2023-01-06 00:39:49 -08:00
jeffvli d1dfbaedaa Move LibraryItem type 2023-01-05 21:59:07 -08:00
jeffvli 3070586104 Improve default table column/header defaults 2023-01-05 20:33:12 -08:00
jeffvli bcfb9dbec3 Fix various api types 2023-01-05 20:32:02 -08:00
jeffvli 888bab50c9 Adjust title line height to better fit 2023-01-05 20:14:46 -08:00
jeffvli 715ee0fa3f Reduce font-weight of table header 2023-01-05 19:57:21 -08:00
jeffvli 1963e93d2e Export non-polymorphic text component 2023-01-05 19:54:59 -08:00
jeffvli ad3728a55d Prevent blank screen on grid-view render
- Add loading prop to before rendering to check for itemCount
2023-01-05 11:03:24 -08:00
jeffvli df4f05b14c Finalize base features for smart playlist editor 2023-01-05 02:27:29 -08:00
jeffvli 0c7a0cc88a Add danger prop for menu items 2023-01-04 23:56:09 -08:00
jeffvli 98ef0b44ec Add error boundaries to individual routes 2023-01-04 22:38:27 -08:00
jeffvli 24f06db2b8 Add playlist "save as" form 2023-01-04 18:37:25 -08:00
jeffvli d63e5f5784 Add owner to playlist update query
- Support smart playlist rules
- Add user list query
2023-01-04 18:33:49 -08:00
jeffvli 75ef43dffb Add initial nd smart playlist ui 2023-01-04 15:54:25 -08:00
jeffvli 65974dbf28 temp 2023-01-04 04:09:24 -08:00
jeffvli 16433457ad Use global state for grid card views
- Prevent re-render when fetching already cached state
2023-01-03 17:41:03 -08:00
jeffvli 19eaf44394 Fix header link route 2023-01-03 17:28:41 -08:00
jeffvli 861fcec14f Change playlist detail bg calc algorithm 2023-01-03 11:48:02 -08:00
jeffvli 72cbd23089 Increase default sidebar width 2023-01-03 11:44:20 -08:00
293 changed files with 26455 additions and 13437 deletions
+4 -27
View File
@@ -4,18 +4,6 @@ about: You're having technical issues. 🐞
labels: 'bug' labels: 'bug'
--- ---
<!-- Please use the following issue template or your issue will be closed -->
## Prerequisites
<!-- If the following boxes are not ALL checked, your issue is likely to be closed -->
- [ ] Using npm
- [ ] Using an up-to-date [`main` branch](https://github.com/electron-react-boilerplate/electron-react-boilerplate/tree/main)
- [ ] Using latest version of devtools. [Check the docs for how to update](https://electron-react-boilerplate.js.org/docs/dev-tools/)
- [ ] Tried solutions mentioned in [#400](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/400)
- [ ] For issue in production release, add devtools output of `DEBUG_PROD=true npm run build && npm start`
## Expected Behavior ## Expected Behavior
<!--- What should have happened? --> <!--- What should have happened? -->
@@ -23,6 +11,8 @@ labels: 'bug'
## Current Behavior ## Current Behavior
<!--- What went wrong? --> <!--- What went wrong? -->
<!-- Add screenshots to help explain your problem -->
<!-- (Open the browser dev tools in the menu or using CTRL + SHIFT + I) -->
## Steps to Reproduce ## Steps to Reproduce
@@ -44,24 +34,11 @@ labels: 'bug'
## Context ## Context
<!--- How has this issue affected you? What are you trying to accomplish? --> <!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Did you make any changes to the boilerplate after cloning it? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
## Your Environment ## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in --> <!--- Include as many relevant details about the environment you experienced the bug in -->
- Node version : - Application version :
- electron-react-boilerplate version or branch :
- Operating System and version : - Operating System and version :
- Link to your project : - Node version (if developing locally) :
<!---
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
Donations will ensure the following:
🔨 Long term maintenance of the project
🛣 Progress on the roadmap
🐛 Quick responses to bug reports and help requests
-->
+3 -13
View File
@@ -4,16 +4,6 @@ about: Ask a question.❓
labels: 'question' labels: 'question'
--- ---
## Summary <!-- Question issues will be closed. -->
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
<!-- What do you need help with? --> <!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
<!---
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
Donations will ensure the following:
🔨 Long term maintenance of the project
🛣 Progress on the roadmap
🐛 Quick responses to bug reports and help requests
-->
+3 -9
View File
@@ -1,15 +1,9 @@
--- ---
name: Feature request name: Feature request
about: You want something added to the boilerplate. 🎉 about: Request a feature to be added to Feishin 🎉
labels: 'enhancement' labels: 'enhancement'
--- ---
<!--- ## What do you want to be added?
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
Donations will ensure the following: ## Additional context
🔨 Long term maintenance of the project
🛣 Progress on the roadmap
🐛 Quick responses to bug reports and help requests
-->
+39
View File
@@ -0,0 +1,39 @@
name: Publish Linux (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force
@@ -1,4 +1,4 @@
name: Publish (Manual) name: Publish Windows and macOS (Manual)
on: workflow_dispatch on: workflow_dispatch
@@ -35,5 +35,5 @@ jobs:
command: | command: |
npm run postinstall npm run postinstall
npm run build npm run build
npm exec electron-builder -- --publish always --win --mac --linux npm exec electron-builder -- --publish always --win --mac
on_retry_command: npm cache clean --force on_retry_command: npm cache clean --force
+54
View File
@@ -0,0 +1,54 @@
name: Comment on pull request
on:
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}
+60
View File
@@ -0,0 +1,60 @@
name: Publish (PR)
on:
pull_request:
branches:
- development
jobs:
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg
+16 -9
View File
@@ -25,26 +25,33 @@
</a> </a>
</p> </p>
Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd). Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Features
- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
## Screenshots
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
## Getting Started ## Getting Started
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
### After installing the server and database
You can access the desktop client via the [latest release](https://github.com/jeffvli/feishin/releases), or you can visit the web client at your server URL (e.g http://192.168.0.1:8643).
## FAQ ## FAQ
### What music servers does Feishin support? ### What music servers does Feishin support?
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Navidrome](https://github.com/navidrome/navidrome) - [Navidrome](https://github.com/navidrome/navidrome)
- ~~[Airsonic](https://github.com/airsonic/airsonic)~~ - [Jellyfin](https://github.com/jellyfin/jellyfin)
- ~~[Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)~~
- ~~[Gonic](https://github.com/sentriz/gonic)~~ - ~~[Gonic](https://github.com/sentriz/gonic)~~
- ~~[Astiga](https://asti.ga/)~~ - ~~[Astiga](https://asti.ga/)~~
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~ - ~~[Supysonic](https://github.com/spl0k/supysonic)~~
Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+2099 -2835
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -2,7 +2,7 @@
"name": "feishin", "name": "feishin",
"productName": "Feishin", "productName": "Feishin",
"description": "Feishin music server", "description": "Feishin music server",
"version": "0.0.1-alpha3", "version": "0.0.1-alpha6",
"scripts": { "scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", "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", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -11,6 +11,7 @@
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:styles": "npx stylelint **/*.tsx", "lint:styles": "npx stylelint **/*.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir", "package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
@@ -50,6 +51,7 @@
"package.json" "package.json"
], ],
"afterSign": ".erb/scripts/notarize.js", "afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1",
"mac": { "mac": {
"target": { "target": {
"target": "default", "target": "default",
@@ -157,6 +159,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0", "@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1", "@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
@@ -169,7 +172,6 @@
"@types/node": "^17.0.23", "@types/node": "^17.0.23",
"@types/react": "^18.0.25", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8", "@types/react-dom": "^18.0.8",
"@types/react-slider": "^1.3.1",
"@types/react-test-renderer": "^17.0.1", "@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
@@ -188,11 +190,10 @@
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1", "css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0", "detect-port": "^1.3.0",
"electron": "^21.2.0", "electron": "^22.3.1",
"electron-builder": "^23.0.3", "electron-builder": "^24.0.0-alpha.13",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1", "electron-notarize": "^1.2.1",
"electron-rebuild": "^3.2.7",
"electronmon": "^2.0.2", "electronmon": "^2.0.2",
"eslint": "^8.30.0", "eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
@@ -253,16 +254,18 @@
"@ag-grid-community/react": "^28.2.1", "@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1", "@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@mantine/core": "^5.9.5", "@mantine/core": "^6.0.8",
"@mantine/dates": "^5.9.5", "@mantine/dates": "^6.0.8",
"@mantine/dropzone": "^5.9.5", "@mantine/dropzone": "^6.0.8",
"@mantine/form": "^5.9.5", "@mantine/form": "^6.0.8",
"@mantine/hooks": "^5.9.5", "@mantine/hooks": "^6.0.8",
"@mantine/modals": "^5.9.5", "@mantine/modals": "^6.0.8",
"@mantine/notifications": "^5.9.5", "@mantine/notifications": "^6.0.8",
"@mantine/spotlight": "^5.9.5", "@mantine/utils": "^6.0.8",
"@tanstack/react-query": "^4.16.1", "@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.16.1", "@tanstack/react-query-devtools": "^4.24.4",
"@ts-rest/core": "^3.19.2",
"axios": "^1.3.6",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1", "electron-localshortcut": "^3.2.1",
@@ -276,7 +279,6 @@
"i18next": "^21.6.16", "i18next": "^21.6.16",
"immer": "^9.0.15", "immer": "^9.0.15",
"is-electron": "^2.2.1", "is-electron": "^2.2.1",
"ky": "^0.33.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
@@ -292,7 +294,6 @@
"react-router": "^6.5.0", "react-router": "^6.5.0",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0", "react-simple-img": "^3.0.0",
"react-slider": "^2.0.4",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.8", "react-window": "^1.8.8",
"react-window-infinite-loader": "^1.0.8", "react-window-infinite-loader": "^1.0.8",
+1988 -3
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "0.0.1-alpha3", "version": "0.0.1-alpha6",
"description": "", "description": "",
"main": "./dist/main/main.js", "main": "./dist/main/main.js",
"author": { "author": {
@@ -12,6 +12,11 @@
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts", "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules" "postinstall": "npm run electron-rebuild && npm run link-modules"
}, },
"dependencies": {}, "dependencies": {
"mpris-service": "^2.1.2"
},
"devDependencies": {
"electron": "22.3.1"
},
"license": "GPL-3.0" "license": "GPL-3.0"
} }
+50 -92
View File
@@ -1,140 +1,98 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import uniq from 'lodash/uniq'; import { getMpvInstance } from '../../../main';
import MpvAPI from 'node-mpv';
import { store } from '../settings';
import { getMainWindow } from '../../../main';
import { PlayerData } from '/@/renderer/store'; import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv'; declare module 'node-mpv';
const BINARY_PATH = store.get('mpv_path') as string | undefined; function wait(timeout: number) {
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined; return new Promise((resolve) => {
const DEFAULT_MPV_PARAMETERS = () => { setTimeout(() => {
const parameters = []; resolve('resolved');
if ( }, timeout);
!MPV_PARAMETERS?.includes('--gapless-audio=weak') || });
!MPV_PARAMETERS?.includes('--gapless-audio=no') || }
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
!MPV_PARAMETERS?.includes('--gapless-audio')
) {
parameters.push('--gapless-audio=yes');
}
if ( ipcMain.on('player-start', async () => {
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') || await getMpvInstance()?.play();
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
) {
parameters.push('--prefetch-playlist=yes');
}
return parameters;
};
const mpv = new MpvAPI(
{
audio_only: true,
auto_restart: true,
binary: BINARY_PATH || '',
time_update: 1,
},
MPV_PARAMETERS
? uniq([...DEFAULT_MPV_PARAMETERS(), ...MPV_PARAMETERS])
: DEFAULT_MPV_PARAMETERS(),
);
mpv.start().catch((error) => {
console.log('error starting mpv', error);
});
mpv.on('status', (status) => {
if (status.property === 'playlist-pos') {
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpv.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpv.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpv.on('paused', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
});
mpv.on('quit', () => {
console.log('mpv quit');
});
// Event output every interval set by time_update, used to update the current time
mpv.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time);
}); });
// Starts the player // Starts the player
ipcMain.on('player-play', async () => { ipcMain.on('player-play', async () => {
await mpv.play(); await getMpvInstance()?.play();
}); });
// Pauses the player // Pauses the player
ipcMain.on('player-pause', async () => { ipcMain.on('player-pause', async () => {
await mpv.pause(); await getMpvInstance()?.pause();
}); });
// Stops the player // Stops the player
ipcMain.on('player-stop', async () => { ipcMain.on('player-stop', async () => {
await mpv.stop(); await getMpvInstance()?.stop();
}); });
// Goes to the next track in the playlist // Goes to the next track in the playlist
ipcMain.on('player-next', async () => { ipcMain.on('player-next', async () => {
await mpv.next(); await getMpvInstance()?.next();
}); });
// Goes to the previous track in the playlist // Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => { ipcMain.on('player-previous', async () => {
await mpv.prev(); await getMpvInstance()?.prev();
}); });
// Seeks forward or backward by the given amount of seconds // Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => { ipcMain.on('player-seek', async (_event, time: number) => {
await mpv.seek(time); await getMpvInstance()?.seek(time);
}); });
// Seeks to the given time in seconds // Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => { ipcMain.on('player-seek-to', async (_event, time: number) => {
await mpv.goToPosition(time); await getMpvInstance()?.goToPosition(time);
}); });
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons // 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) => {
if (data.queue.current) { if (!data.queue.current && !data.queue.next) {
await mpv.load(data.queue.current.streamUrl, 'replace'); await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
return;
} }
if (data.queue.next) { let complete = false;
await mpv.load(data.queue.next.streamUrl, 'append');
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');
}
complete = true;
} catch (err) {
console.error(err);
await wait(500);
}
} }
}); });
// Replaces the queue in position 1 to the given data // Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => { ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await mpv.getPlaylistSize(); const size = await getMpvInstance()?.getPlaylistSize();
if (!size) {
return;
}
if (size > 1) { if (size > 1) {
await mpv.playlistRemove(1); await getMpvInstance()?.playlistRemove(1);
} }
if (data.queue.next) { if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append'); await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
} }
}); });
@@ -143,23 +101,23 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue // Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without // This allows us to easily set update the next song in the queue without
// disturbing the currently playing song // disturbing the currently playing song
await mpv.playlistRemove(0); await getMpvInstance()?.playlistRemove(0);
if (data.queue.next) { if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append'); await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
} }
}); });
// Sets the volume to the given value (0-100) // Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => { ipcMain.on('player-volume', async (_event, value: number) => {
await mpv.volume(value); await getMpvInstance()?.volume(value);
}); });
// Toggles the mute status // Toggles the mute status
ipcMain.on('player-mute', async () => { ipcMain.on('player-mute', async () => {
await mpv.mute(); await getMpvInstance()?.mute();
}); });
ipcMain.on('player-quit', async () => { ipcMain.on('player-quit', async () => {
await mpv.quit(); await getMpvInstance()?.stop();
}); });
+1
View File
@@ -0,0 +1 @@
import './mpris';
+168
View File
@@ -0,0 +1,168 @@
import { ipcMain } from 'electron';
import Player from 'mpris-service';
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
import { getMainWindow } from '../../main';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
const mprisPlayer = Player({
identity: 'Feishin',
maximumRate: 1.0,
minimumRate: 1.0,
name: 'Feishin',
rate: 1.0,
supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'],
});
mprisPlayer.on('quit', () => {
process.exit();
});
mprisPlayer.on('stop', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('pause', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('play', () => {
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
});
mprisPlayer.on('playpause', () => {
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
} else {
mprisPlayer.playbackStatus = 'Paused';
}
});
mprisPlayer.on('next', () => {
getMainWindow()?.webContents.send('renderer-player-next');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
}
});
mprisPlayer.on('previous', () => {
getMainWindow()?.webContents.send('renderer-player-previous');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
}
});
mprisPlayer.on('volume', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-volume', {
volume: event,
});
});
mprisPlayer.on('shuffle', (event: boolean) => {
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event;
});
mprisPlayer.on('loopStatus', (event: string) => {
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event;
});
mprisPlayer.on('position', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-position', {
position: event.position / 1e6,
});
});
mprisPlayer.on('seek', (event: number) => {
getMainWindow()?.webContents.send('mpris-request-seek', {
offset: event / 1e6,
});
});
ipcMain.on('mpris-update-position', (_event, arg) => {
mprisPlayer.getPosition = () => arg * 1e6;
});
ipcMain.on('mpris-update-seek', (_event, arg) => {
mprisPlayer.seeked(arg * 1e6);
});
ipcMain.on('mpris-update-volume', (_event, arg) => {
mprisPlayer.volume = Number(arg);
});
ipcMain.on('mpris-update-repeat', (_event, arg) => {
mprisPlayer.loopStatus = arg;
});
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
mprisPlayer.shuffle = arg;
});
ipcMain.on(
'mpris-update-song',
(
_event,
args: {
currentTime: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
song: QueueSong;
status: PlayerStatus;
},
) => {
const { song, status, repeat, shuffle } = args || {};
try {
mprisPlayer.playbackStatus = status;
if (repeat) {
mprisPlayer.loopStatus =
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
}
if (shuffle) {
mprisPlayer.shuffle = shuffle;
}
if (!song) return;
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
'mpris:trackid': song?.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
'xesam:artist':
song.artists?.length !== 0
? song.artists?.map((artist: RelatedArtist) => artist.name)
: null,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
};
} catch (err) {
console.log(err);
}
},
);
+263 -22
View File
@@ -9,16 +9,29 @@
* `./src/main.js` using webpack. This gives us some performance wins. * `./src/main.js` using webpack. This gives us some performance wins.
*/ */
import path from 'path'; import path from 'path';
import { app, BrowserWindow, shell, ipcMain, globalShortcut } from 'electron'; import {
app,
BrowserWindow,
shell,
ipcMain,
globalShortcut,
Tray,
Menu,
nativeImage,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut'; import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log'; import log from 'electron-log';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index'; import { store } from './features/core/settings/index';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { resolveHtmlPath } from './utils'; import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import './features'; import './features';
declare module 'node-mpv';
export default class AppUpdater { export default class AppUpdater {
constructor() { constructor() {
log.transports.file.level = 'info'; log.transports.file.level = 'info';
@@ -27,7 +40,18 @@ export default class AppUpdater {
} }
} }
process.on('uncaughtException', (error: any) => {
console.log('Error in main process', error);
});
if (store.get('ignore_ssl')) {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let exitFromTray = false;
let forceQuit = false;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support'); const sourceMapSupport = require('source-map-support');
@@ -53,19 +77,111 @@ const installExtensions = async () => {
.catch(console.log); .catch(console.log);
}; };
const singleInstance = app.requestSingleInstanceLock();
if (!singleInstance) {
app.quit();
}
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
export const getMainWindow = () => {
return mainWindow;
};
const createWinThumbarButtons = () => {
if (isWindows()) {
console.log('setting buttons');
getMainWindow()?.setThumbarButtons([
{
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
icon: nativeImage.createFromPath(getAssetPath('skip-previous.png')),
tooltip: 'Previous Track',
},
{
click: () => getMainWindow()?.webContents.send('renderer-player-play-pause'),
icon: nativeImage.createFromPath(getAssetPath('play-circle.png')),
tooltip: 'Play/Pause',
},
{
click: () => getMainWindow()?.webContents.send('renderer-player-next'),
icon: nativeImage.createFromPath(getAssetPath('skip-next.png')),
tooltip: 'Next Track',
},
]);
}
};
const createTray = () => {
if (isMacOS()) {
return;
}
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico'));
const contextMenu = Menu.buildFromTemplate([
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-play-pause');
},
label: 'Play/Pause',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-next');
},
label: 'Next Track',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-previous');
},
label: 'Previous Track',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-stop');
},
label: 'Stop',
},
{
type: 'separator',
},
{
click: () => {
mainWindow?.show();
createWinThumbarButtons();
},
label: 'Open main window',
},
{
click: () => {
exitFromTray = true;
app.quit();
},
label: 'Quit',
},
]);
tray.on('double-click', () => {
mainWindow?.show();
createWinThumbarButtons();
});
tray.setToolTip('Feishin');
tray.setContextMenu(contextMenu);
};
const createWindow = async () => { const createWindow = async () => {
if (isDevelopment) { if (isDevelopment) {
await installExtensions(); await installExtensions();
} }
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
frame: false, frame: false,
height: 900, height: 900,
@@ -81,6 +197,7 @@ const createWindow = async () => {
preload: app.isPackaged preload: app.isPackaged
? path.join(__dirname, 'preload.js') ? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'), : path.join(__dirname, '../../.erb/dll/preload.js'),
webSecurity: store.get('ignore_cors') ? false : undefined,
}, },
width: 1440, width: 1440,
}); });
@@ -89,6 +206,10 @@ const createWindow = async () => {
mainWindow?.webContents.openDevTools(); mainWindow?.webContents.openDevTools();
}); });
ipcMain.on('window-dev-tools', () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-maximize', () => { ipcMain.on('window-maximize', () => {
mainWindow?.maximize(); mainWindow?.maximize();
}); });
@@ -134,6 +255,7 @@ const createWindow = async () => {
mainWindow.minimize(); mainWindow.minimize();
} else { } else {
mainWindow.show(); mainWindow.show();
createWinThumbarButtons();
} }
}); });
@@ -141,6 +263,33 @@ const createWindow = async () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.on('close', (event) => {
if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) {
exitFromTray = true;
}
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('minimize', (event: any) => {
if (store.get('window_minimize_to_tray') === true) {
event.preventDefault();
mainWindow?.hide();
}
});
if (isWindows()) {
app.setAppUserModelId(process.execPath);
}
if (isMacOS()) {
app.on('before-quit', () => {
forceQuit = true;
});
}
const menuBuilder = new MenuBuilder(mainWindow); const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu(); menuBuilder.buildMenu();
@@ -150,30 +299,121 @@ const createWindow = async () => {
return { action: 'deny' }; return { action: 'deny' };
}); });
// Remove this if your app does not use auto updates if (store.get('disable_auto_updates') !== true) {
// eslint-disable-next-line // eslint-disable-next-line
new AppUpdater(); new AppUpdater();
}
}; };
/**
* Add event listeners...
*/
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService'); app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
export const getMainWindow = () => { const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
return mainWindow; const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
'--prefetch-playlist=yes',
'--prefetch-playlist',
];
const DEFAULT_MPV_PARAMETERS = () => {
const parameters = [];
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes');
}
return parameters;
}; };
let mpvInstance: MpvAPI | null = null;
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
const { extraParameters, properties } = data;
mpvInstance = new MpvAPI(
{
audio_only: true,
auto_restart: false,
binary: MPV_BINARY_PATH || '',
time_update: 1,
},
MPV_PARAMETERS || extraParameters
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
: DEFAULT_MPV_PARAMETERS(),
);
mpvInstance.setMultipleProperties(properties || {});
mpvInstance.start().catch((error) => {
console.log('error starting mpv', error);
});
mpvInstance.on('status', (status) => {
if (status.property === 'playlist-pos') {
if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next');
}
}
});
// Automatically updates the play button when the player is playing
mpvInstance.on('resumed', () => {
getMainWindow()?.webContents.send('renderer-player-play');
});
// Automatically updates the play button when the player is stopped
mpvInstance.on('stopped', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
});
// Automatically updates the play button when the player is paused
mpvInstance.on('paused', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
});
// Event output every interval set by time_update, used to update the current time
mpvInstance.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time);
});
};
export const getMpvInstance = () => {
return mpvInstance;
};
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
if (data.length === 0) {
return;
}
if (data.length === 1) {
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
} else {
getMpvInstance()?.setMultipleProperties(data);
}
});
ipcMain.on(
'player-restart',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
mpvInstance?.quit();
createMpv(data);
},
);
app.on('before-quit', () => { app.on('before-quit', () => {
mainWindow?.webContents.send('renderer-player-quit'); getMpvInstance()?.stop();
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
getMpvInstance()?.quit();
// Respect the OSX convention of having the application in memory even // Respect the OSX convention of having the application in memory even
// after all windows have been closed // after all windows have been closed
globalShortcut.unregisterAll(); if (isMacOS()) {
if (process.platform !== 'darwin') { mainWindow = null;
} else {
app.quit(); app.quit();
} }
}); });
@@ -182,6 +422,7 @@ app
.whenReady() .whenReady()
.then(() => { .then(() => {
createWindow(); createWindow();
createTray();
app.on('activate', () => { app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the // On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open. // dock icon is clicked and there are no other windows open.
+5 -97
View File
@@ -1,109 +1,17 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import { contextBridge } from 'electron';
import { PlayerData } from '../renderer/store';
import { browser } from './preload/browser'; import { browser } from './preload/browser';
import { ipc } from './preload/ipc'; import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings'; import { localSettings } from './preload/local-settings';
import { mpris } from './preload/mpris';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player'; import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
browser, browser,
ipc, ipc,
ipcRenderer: {
APP_RESTART() {
ipcRenderer.send('app-restart');
},
PLAYER_AUTO_NEXT(data: PlayerData) {
ipcRenderer.send('player-auto-next', data);
},
PLAYER_CURRENT_TIME() {
ipcRenderer.send('player-current-time');
},
PLAYER_MEDIA_KEYS_DISABLE() {
ipcRenderer.send('global-media-keys-disable');
},
PLAYER_MEDIA_KEYS_ENABLE() {
ipcRenderer.send('global-media-keys-enable');
},
PLAYER_MUTE() {
ipcRenderer.send('player-mute');
},
PLAYER_NEXT() {
ipcRenderer.send('player-next');
},
PLAYER_PAUSE() {
ipcRenderer.send('player-pause');
},
PLAYER_PLAY() {
ipcRenderer.send('player-play');
},
PLAYER_PREVIOUS() {
ipcRenderer.send('player-previous');
},
PLAYER_SEEK(seconds: number) {
ipcRenderer.send('player-seek', seconds);
},
PLAYER_SEEK_TO(seconds: number) {
ipcRenderer.send('player-seek-to', seconds);
},
PLAYER_SET_QUEUE(data: PlayerData) {
ipcRenderer.send('player-set-queue', data);
},
PLAYER_SET_QUEUE_NEXT(data: PlayerData) {
ipcRenderer.send('player-set-queue-next', data);
},
PLAYER_STOP() {
ipcRenderer.send('player-stop');
},
PLAYER_VOLUME(value: number) {
ipcRenderer.send('player-volume', value);
},
RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-auto-next', cb);
},
RENDERER_PLAYER_CURRENT_TIME(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-current-time', cb);
},
RENDERER_PLAYER_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-next', cb);
},
RENDERER_PLAYER_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-pause', cb);
},
RENDERER_PLAYER_PLAY(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-play', cb);
},
RENDERER_PLAYER_PLAY_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-play-pause', cb);
},
RENDERER_PLAYER_PREVIOUS(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-previous', cb);
},
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void) {
ipcRenderer.on('renderer-player-stop', cb);
},
SETTINGS_GET(data: { property: string }) {
return ipcRenderer.invoke('settings-get', data);
},
SETTINGS_SET(data: { property: string; value: any }) {
ipcRenderer.send('settings-set', data);
},
removeAllListeners(value: string) {
ipcRenderer.removeAllListeners(value);
},
windowClose() {
ipcRenderer.send('window-close');
},
windowMaximize() {
ipcRenderer.send('window-maximize');
},
windowMinimize() {
ipcRenderer.send('window-minimize');
},
windowUnmaximize() {
ipcRenderer.send('window-unmaximize');
},
},
localSettings, localSettings,
mpris,
mpvPlayer, mpvPlayer,
mpvPlayerListener, mpvPlayerListener,
utils,
}); });
+5
View File
@@ -13,7 +13,12 @@ const unmaximize = () => {
ipcRenderer.send('window-unmaximize'); ipcRenderer.send('window-unmaximize');
}; };
const devtools = () => {
ipcRenderer.send('window-dev-tools');
};
export const browser = { export const browser = {
devtools,
exit, exit,
maximize, maximize,
minimize, minimize,
+70
View File
@@ -0,0 +1,70 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { QueueSong } from '/@/renderer/api/types';
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
ipcRenderer.send('mpris-update-song', args);
};
const updatePosition = (timeSec: number) => {
ipcRenderer.send('mpris-update-position', timeSec);
};
const updateSeek = (timeSec: number) => {
ipcRenderer.send('mpris-update-seek', timeSec);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('mpris-update-volume', volume);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('mpris-update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('mpris-update-shuffle', shuffle);
};
const toggleRepeat = () => {
ipcRenderer.send('mpris-toggle-repeat');
};
const toggleShuffle = () => {
ipcRenderer.send('mpris-toggle-shuffle');
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('mpris-request-position', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('mpris-request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('mpris-request-volume', cb);
};
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb);
};
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
};
export const mpris = {
requestPosition,
requestSeek,
requestToggleRepeat,
requestToggleShuffle,
requestVolume,
toggleRepeat,
toggleShuffle,
updatePosition,
updateRepeat,
updateSeek,
updateShuffle,
updateSong,
updateVolume,
};
+22
View File
@@ -1,6 +1,15 @@
import { ipcRenderer, IpcRendererEvent } from 'electron'; import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store'; import { PlayerData } from '/@/renderer/store';
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data);
};
const setProperties = (data: Record<string, any>) => {
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
};
const autoNext = (data: PlayerData) => { const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data); ipcRenderer.send('player-auto-next', data);
}; };
@@ -8,36 +17,47 @@ const autoNext = (data: PlayerData) => {
const currentTime = () => { const currentTime = () => {
ipcRenderer.send('player-current-time'); ipcRenderer.send('player-current-time');
}; };
const mute = () => { const mute = () => {
ipcRenderer.send('player-mute'); ipcRenderer.send('player-mute');
}; };
const next = () => { const next = () => {
ipcRenderer.send('player-next'); ipcRenderer.send('player-next');
}; };
const pause = () => { const pause = () => {
ipcRenderer.send('player-pause'); ipcRenderer.send('player-pause');
}; };
const play = () => { const play = () => {
ipcRenderer.send('player-play'); ipcRenderer.send('player-play');
}; };
const previous = () => { const previous = () => {
ipcRenderer.send('player-previous'); ipcRenderer.send('player-previous');
}; };
const seek = (seconds: number) => { const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds); ipcRenderer.send('player-seek', seconds);
}; };
const seekTo = (seconds: number) => { const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds); ipcRenderer.send('player-seek-to', seconds);
}; };
const setQueue = (data: PlayerData) => { const setQueue = (data: PlayerData) => {
ipcRenderer.send('player-set-queue', data); ipcRenderer.send('player-set-queue', data);
}; };
const setQueueNext = (data: PlayerData) => { const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data); ipcRenderer.send('player-set-queue-next', data);
}; };
const stop = () => { const stop = () => {
ipcRenderer.send('player-stop'); ipcRenderer.send('player-stop');
}; };
const volume = (value: number) => { const volume = (value: number) => {
ipcRenderer.send('player-volume', value); ipcRenderer.send('player-volume', value);
}; };
@@ -91,8 +111,10 @@ export const mpvPlayer = {
play, play,
previous, previous,
quit, quit,
restart,
seek, seek,
seekTo, seekTo,
setProperties,
setQueue, setQueue,
setQueueNext, setQueueNext,
stop, stop,
+7
View File
@@ -0,0 +1,7 @@
import { isMacOS, isWindows, isLinux } from '../utils';
export const utils = {
isLinux,
isMacOS,
isWindows,
};
+305 -108
View File
@@ -1,69 +1,91 @@
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { navidromeApi } from '/@/renderer/api/navidrome.api'; import { toast } from '/@/renderer/components/toast/index';
import { toast } from '/@/renderer/components/toast';
import type { import type {
AlbumDetailArgs, AlbumDetailArgs,
RawAlbumDetailResponse,
RawAlbumListResponse,
AlbumListArgs, AlbumListArgs,
SongListArgs, SongListArgs,
RawSongListResponse,
SongDetailArgs, SongDetailArgs,
RawSongDetailResponse,
AlbumArtistDetailArgs, AlbumArtistDetailArgs,
RawAlbumArtistDetailResponse,
AlbumArtistListArgs, AlbumArtistListArgs,
RawAlbumArtistListResponse, SetRatingArgs,
RatingArgs,
RawRatingResponse,
FavoriteArgs,
RawFavoriteResponse,
GenreListArgs, GenreListArgs,
RawGenreListResponse,
CreatePlaylistArgs, CreatePlaylistArgs,
RawCreatePlaylistResponse,
DeletePlaylistArgs, DeletePlaylistArgs,
RawDeletePlaylistResponse,
PlaylistDetailArgs, PlaylistDetailArgs,
RawPlaylistDetailResponse,
PlaylistListArgs, PlaylistListArgs,
RawPlaylistListResponse,
MusicFolderListArgs, MusicFolderListArgs,
RawMusicFolderListResponse,
PlaylistSongListArgs, PlaylistSongListArgs,
ArtistListArgs, ArtistListArgs,
RawArtistListResponse,
UpdatePlaylistArgs, UpdatePlaylistArgs,
RawUpdatePlaylistResponse, UserListArgs,
FavoriteArgs,
TopSongListArgs,
AddToPlaylistArgs,
AddToPlaylistResponse,
RemoveFromPlaylistArgs,
RemoveFromPlaylistResponse,
ScrobbleArgs,
ScrobbleResponse,
AlbumArtistDetailResponse,
FavoriteResponse,
CreatePlaylistResponse,
AlbumArtistListResponse,
AlbumDetailResponse,
AlbumListResponse,
ArtistListResponse,
GenreListResponse,
MusicFolderListResponse,
PlaylistDetailResponse,
PlaylistListResponse,
RatingResponse,
SongDetailResponse,
SongListResponse,
TopSongListResponse,
UpdatePlaylistResponse,
UserListResponse,
AuthenticationResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api'; import { ServerType } from '/@/renderer/types';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api'; import { DeletePlaylistResponse } 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<{ export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
url: string,
body: { password: string; username: string },
) => Promise<AuthenticationResponse>;
clearPlaylist: () => void; clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>; createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>; createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
deleteFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>; deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<RawDeletePlaylistResponse>; deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<RawAlbumArtistDetailResponse>; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<RawAlbumArtistListResponse>; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>; getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>; getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
getArtistDetail: () => void; getArtistDetail: () => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>; getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getFavoritesList: () => void; getFavoritesList: () => void;
getFolderItemList: () => void; getFolderItemList: () => void;
getFolderList: () => void; getFolderList: () => void;
getFolderSongs: () => void; getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<RawGenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<RawMusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<RawPlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<RawPlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>; getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>; getSongList: (args: SongListArgs) => Promise<SongListResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>; }>;
type ApiController = { type ApiController = {
@@ -74,86 +96,105 @@ type ApiController = {
const endpoints: ApiController = { const endpoints: ApiController = {
jellyfin: { jellyfin: {
addToPlaylist: jfController.addToPlaylist,
authenticate: jfController.authenticate,
clearPlaylist: undefined, clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite, createFavorite: jfController.createFavorite,
createPlaylist: jellyfinApi.createPlaylist, createPlaylist: jfController.createPlaylist,
deleteFavorite: jellyfinApi.deleteFavorite, deleteFavorite: jfController.deleteFavorite,
deletePlaylist: jellyfinApi.deletePlaylist, deletePlaylist: jfController.deletePlaylist,
getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail, getAlbumArtistDetail: jfController.getAlbumArtistDetail,
getAlbumArtistList: jellyfinApi.getAlbumArtistList, getAlbumArtistList: jfController.getAlbumArtistList,
getAlbumDetail: jellyfinApi.getAlbumDetail, getAlbumDetail: jfController.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList, getAlbumList: jfController.getAlbumList,
getArtistDetail: 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,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
navidrome: {
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,
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: navidromeApi.getGenreList, getGenreList: jfController.getGenreList,
getMusicFolderList: subsonicApi.getMusicFolderList, getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: navidromeApi.getPlaylistDetail, getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: navidromeApi.getPlaylistList, getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: navidromeApi.getPlaylistSongList, getPlaylistSongList: jfController.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail, getSongDetail: undefined,
getSongList: navidromeApi.getSongList, getSongList: jfController.getSongList,
updatePlaylist: navidromeApi.updatePlaylist, getTopSongs: jfController.getTopSongList,
updateRating: subsonicApi.updateRating, getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble,
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,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble,
setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist,
}, },
subsonic: { subsonic: {
authenticate: ssController.authenticate,
clearPlaylist: undefined, clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite, createFavorite: ssController.createFavorite,
createPlaylist: undefined, createPlaylist: undefined,
deleteFavorite: subsonicApi.deleteFavorite, deleteFavorite: ssController.removeFavorite,
deletePlaylist: undefined, deletePlaylist: undefined,
getAlbumArtistDetail: subsonicApi.getAlbumArtistDetail, getAlbumArtistDetail: undefined,
getAlbumArtistList: subsonicApi.getAlbumArtistList, getAlbumArtistList: undefined,
getAlbumDetail: subsonicApi.getAlbumDetail, getAlbumDetail: undefined,
getAlbumList: subsonicApi.getAlbumList, getAlbumList: undefined,
getArtistDetail: undefined, getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined, getArtistList: undefined,
getFavoritesList: undefined, getFavoritesList: undefined,
getFolderItemList: undefined, getFolderItemList: undefined,
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: undefined, getGenreList: undefined,
getMusicFolderList: subsonicApi.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined, getPlaylistDetail: undefined,
getPlaylistList: undefined, getPlaylistList: undefined,
getSongDetail: undefined, getSongDetail: undefined,
getSongList: undefined, getSongList: undefined,
getTopSongs: ssController.getTopSongList,
getUserList: undefined,
scrobble: ssController.scrobble,
setRating: undefined,
updatePlaylist: undefined, updatePlaylist: undefined,
updateRating: undefined,
}, },
}; };
const apiController = (endpoint: keyof ControllerEndpoint) => { const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
const serverType = useAuthStore.getState().currentServer?.type; const serverType = type || useAuthStore.getState().currentServer?.type;
if (!serverType) { if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' }); toast.error({ message: 'No server selected', title: 'Unable to route request' });
@@ -173,63 +214,214 @@ const apiController = (endpoint: keyof ControllerEndpoint) => {
return endpoints[serverType][endpoint]; 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) => { 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) => { 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) => { const getSongList = async (args: SongListArgs) => {
return (apiController('getSongList') as ControllerEndpoint['getSongList'])?.(args); return (
apiController(
'getSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongList']
)?.(args);
}; };
const getMusicFolderList = async (args: MusicFolderListArgs) => { 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) => { 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',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getAlbumArtistDetail']
)?.(args);
}; };
const getAlbumArtistList = async (args: AlbumArtistListArgs) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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',
args.apiClientProps.server?.type,
) as ControllerEndpoint['addToPlaylist']
)?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (
apiController(
'removeFromPlaylist',
args.apiClientProps.server?.type,
) as ControllerEndpoint['removeFromPlaylist']
)?.(args);
}; };
const getPlaylistDetail = async (args: PlaylistDetailArgs) => { 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) => { const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.( return (
args, apiController(
); 'getPlaylistSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getPlaylistSongList']
)?.(args);
};
const getUserList = async (args: UserListArgs) => {
return (
apiController(
'getUserList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getUserList']
)?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'createFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['createFavorite']
)?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (
apiController(
'deleteFavorite',
args.apiClientProps.server?.type,
) as ControllerEndpoint['deleteFavorite']
)?.(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',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getTopSongs']
)?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
)?.(args);
}; };
export const controller = { export const controller = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist, createPlaylist,
deleteFavorite,
deletePlaylist, deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList, getAlbumArtistList,
getAlbumDetail, getAlbumDetail,
getAlbumList, getAlbumList,
@@ -240,5 +432,10 @@ export const controller = {
getPlaylistList, getPlaylistList,
getPlaylistSongList, getPlaylistSongList,
getSongList, getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
updatePlaylist, updatePlaylist,
updateRating,
}; };
-2
View File
@@ -1,7 +1,5 @@
import { controller } from '/@/renderer/api/controller'; import { controller } from '/@/renderer/api/controller';
import { normalize } from '/@/renderer/api/normalize';
export const api = { export const api = {
controller, controller,
normalize,
}; };
-847
View File
@@ -1,847 +0,0 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type {
JFAlbum,
JFAlbumArtist,
JFAlbumArtistDetail,
JFAlbumArtistDetailResponse,
JFAlbumArtistList,
JFAlbumArtistListParams,
JFAlbumArtistListResponse,
JFAlbumDetail,
JFAlbumDetailResponse,
JFAlbumList,
JFAlbumListParams,
JFAlbumListResponse,
JFArtistList,
JFArtistListParams,
JFArtistListResponse,
JFAuthenticate,
JFCreatePlaylistResponse,
JFGenreList,
JFGenreListResponse,
JFMusicFolderList,
JFMusicFolderListResponse,
JFPlaylist,
JFPlaylistDetail,
JFPlaylistDetailResponse,
JFPlaylistList,
JFPlaylistListResponse,
JFSong,
JFSongList,
JFSongListParams,
JFSongListResponse,
} from '/@/renderer/api/jellyfin.types';
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
import {
Album,
AlbumArtist,
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistListArgs,
AuthenticationResponse,
CreatePlaylistArgs,
CreatePlaylistResponse,
DeletePlaylistArgs,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
MusicFolderListArgs,
Playlist,
PlaylistDetailArgs,
PlaylistListArgs,
playlistListSortMap,
PlaylistSongListArgs,
Song,
SongListArgs,
songListSortMap,
albumListSortMap,
artistListSortMap,
sortOrderMap,
albumArtistListSortMap,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
const getCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const api = ky.create({});
const authenticate = async (
url: string,
body: {
password: string;
username: string;
},
): Promise<AuthenticationResponse> => {
const cleanServerUrl = url.replace(/\/$/, '');
const data = await ky
.post(`${cleanServerUrl}/users/authenticatebyname`, {
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
},
json: {
pw: body.password,
username: body.username,
},
})
.json<JFAuthenticate>();
return {
credential: data.AccessToken,
userId: data.User.Id,
username: data.User.Name,
};
};
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<JFMusicFolderList> => {
const { server, signal } = args;
const userId = useAuthStore.getState().currentServer?.userId;
const data = await api
.get(`users/${userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
signal,
})
.json<JFMusicFolderListResponse>();
const musicFolders = data.Items.filter(
(folder) => folder.CollectionType === JFCollectionType.MUSIC,
);
return musicFolders;
};
const getGenreList = async (args: GenreListArgs): Promise<JFGenreList> => {
const { signal, server } = args;
const data = await api
.get('genres', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
signal,
})
.json<JFGenreListResponse>();
return data;
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbumArtistDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres',
};
const data = await api
.get(`users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumArtistDetailResponse>();
return data;
};
// const getAlbumArtistAlbums = () => {
// const { data: albumData } = await api.get(`/users/${auth.username}/items`, {
// params: {
// artistIds: options.id,
// fields: 'AudioInfo, ParentId, Genres, DateCreated, ChildCount, ParentId',
// includeItemTypes: 'MusicAlbum',
// parentId: options.musicFolderId,
// recursive: true,
// sortBy: 'SortName',
// },
// });
// const { data: similarData } = await api.get(`/artists/${options.id}/similar`, {
// params: { limit: 15, parentId: options.musicFolderId, userId: auth.username },
// });
// };
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArtistList> => {
const { query, server, signal } = args;
const searchParams: JFAlbumArtistListParams = {
fields: 'Genres, DateCreated, ExternalUrls, Overview',
imageTypeLimit: 1,
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
searchTerm: query.searchTerm,
sortBy: albumArtistListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
userId: server?.userId || undefined,
};
const data = await api
.get('artists/albumArtists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumArtistListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getArtistList = async (args: ArtistListArgs): Promise<JFArtistList> => {
const { query, server, signal } = args;
const searchParams: JFArtistListParams = {
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
sortBy: artistListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
};
const data = await api
.get('artists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFArtistListResponse>();
return data;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<JFAlbumDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres, DateCreated, ChildCount',
};
const data = await api
.get(`users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams,
signal,
})
.json<JFAlbumDetailResponse>();
const songsSearchParams = {
fields: 'Genres, DateCreated, MediaSources, ParentId',
parentId: query.id,
sortBy: 'SortName',
};
const songsData = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: songsSearchParams,
signal,
})
.json<JFSongListResponse>();
return { ...data, songs: songsData.Items };
};
const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
const { query, server, signal } = args;
const yearsGroup = [];
if (query.jfParams?.minYear && query.jfParams?.maxYear) {
for (let i = Number(query.jfParams.minYear); i <= Number(query.jfParams.maxYear); i += 1) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
const searchParams: JFAlbumListParams & { maxYear?: number; minYear?: number } = {
includeItemTypes: 'MusicAlbum',
limit: query.limit,
parentId: query.musicFolderId,
recursive: true,
searchTerm: query.searchTerm,
sortBy: albumListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
...query.jfParams,
maxYear: undefined,
minYear: undefined,
years: yearsFilter,
};
const data = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFAlbumListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
const { query, server, signal } = args;
const yearsGroup = [];
if (query.jfParams?.minYear && query.jfParams?.maxYear) {
for (let i = Number(query.jfParams.minYear); i <= Number(query.jfParams.maxYear); i += 1) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? getCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds ? getCommaDelimitedString(query.albumIds) : undefined;
const artistIdsFilter = query.artistIds ? getCommaDelimitedString(query.artistIds) : undefined;
const searchParams: JFSongListParams & { maxYear?: number; minYear?: number } = {
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],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
...query.jfParams,
maxYear: undefined,
minYear: undefined,
years: yearsFilter,
};
const data = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFSongListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
ids: query.id,
};
const data = await api
.get(`users/${server?.userId}/items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams,
signal,
})
.json<JFPlaylistDetailResponse>();
return data;
};
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongList> => {
const { query, server, signal } = args;
const searchParams: JFSongListParams = {
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: server?.userId || '',
};
const data = await api
.get(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFSongListResponse>();
return {
items: data.Items,
startIndex: query.startIndex,
totalRecordCount: data.TotalRecordCount,
};
};
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
const { query, server, signal } = args;
const searchParams = {
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
includeItemTypes: 'Playlist',
limit: query.limit,
recursive: true,
sortBy: playlistListSortMap.jellyfin[query.sortBy],
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex,
};
const data = await api
.get(`users/${server?.userId}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistListResponse>();
const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
return {
items: playlistItems,
startIndex: 0,
totalRecordCount: playlistItems.length,
};
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const body = {
MediaType: 'Audio',
Name: query.name,
Overview: query.comment || '',
UserId: server?.userId,
};
const data = await api
.post('playlists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: body,
prefixUrl: server?.url,
})
.json<JFCreatePlaylistResponse>();
return {
id: data.Id,
name: query.name,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, server } = args;
const json = {
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
MediaType: 'Audio',
Name: body.name,
Overview: body.comment || '',
PremiereDate: null,
ProviderIds: {},
Tags: [],
UserId: server?.userId, // Required
};
await api
.post(`items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json,
prefixUrl: server?.url,
})
.json<null>();
return {
id: query.id,
};
};
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
const { query, server } = args;
await api.delete(`items/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return null;
};
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, server } = args;
await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return {
id: query.id,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { query, server } = args;
await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
return {
id: query.id,
};
};
const getStreamUrl = (args: {
container?: string;
deviceId: string;
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem;
}) => {
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: JFAlbumArtist;
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: JFSong; size: number }) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (!args.item?.AlbumPrimaryImageTag) {
return null;
}
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
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: JFSong,
server: ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
albumId: item.AlbumId,
artistName: item.ArtistItems[0]?.Name,
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
bpm: null,
channels: 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 || 300 }),
isFavorite: (item.UserData && item.UserData.IsFavorite) || false,
lastPlayedAt: null,
name: item.Name,
note: null,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null,
serverId: server.id,
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,
type: ServerType.JELLYFIN,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
};
};
const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: number): Album => {
return {
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, 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,
isFavorite: item.UserData?.IsFavorite || false,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
rating: null,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear,
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,
};
};
const normalizeAlbumArtist = (
item: JFAlbumArtist,
server: ServerListItem,
imageSize?: number,
): AlbumArtist => {
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,
}),
isFavorite: item.UserData.IsFavorite || false,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData.PlayCount,
rating: null,
songCount: null,
};
};
const normalizePlaylist = (
item: JFPlaylist,
server: ServerListItem,
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,
name: item.Name,
public: null,
rules: null,
size: null,
songCount: item?.ChildCount || null,
userId: null,
username: null,
};
};
// 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: any) => {
// return {
// albumCount: undefined,
// id: item.Id,
// songCount: undefined,
// title: item.Name,
// type: Item.Genre,
// uniqueId: nanoid(),
// };
// };
// 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 jellyfinApi = {
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongList,
updatePlaylist,
};
export const jfNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
};
+25
View File
@@ -59,6 +59,25 @@ export type JFSongList = {
totalRecordCount: number; totalRecordCount: number;
}; };
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFAddToPlaylist = null;
export type JFRemoveFromPlaylistResponse = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylist = null;
export interface JFPlaylistListResponse extends JFBasePaginatedResponse { export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[]; Items: JFPlaylist[];
} }
@@ -173,6 +192,10 @@ export type JFAlbumArtist = {
PlaybackPositionTicks: number; PlaybackPositionTicks: number;
Played: boolean; Played: boolean;
}; };
} & {
similarArtists: {
items: JFAlbumArtist[];
};
}; };
export type JFArtist = { export type JFArtist = {
@@ -248,6 +271,7 @@ export type JFSong = {
MediaType: string; MediaType: string;
Name: string; Name: string;
ParentIndexNumber: number; ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string; PremiereDate?: string;
ProductionYear: number; ProductionYear: number;
RunTimeTicks: number; RunTimeTicks: number;
@@ -543,6 +567,7 @@ export enum JFSongListSort {
ALBUM = 'Album,SortName', ALBUM = 'Album,SortName',
ALBUM_ARTIST = 'AlbumArtist,Album,SortName', ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
ARTIST = 'Artist,Album,SortName', ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName', DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name,SortName', NAME = 'Name,SortName',
PLAY_COUNT = 'PlayCount,SortName', PLAY_COUNT = 'PlayCount,SortName',
+336
View File
@@ -0,0 +1,336 @@
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,
},
},
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,728 @@
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,
} 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';
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: {
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;
};
export const jfController = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistList,
getGenreList,
getMusicFolderList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
removeFromPlaylist,
scrobble,
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 null;
}
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
}
if (!args.item?.AlbumPrimaryImageTag) {
return null;
}
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
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,
};
+667
View File
@@ -0,0 +1,667 @@
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(),
EnableImageTypes: z.string().optional(),
EnableTotalRecordCount: z.boolean().optional(),
EnableUserData: z.boolean().optional(),
ExcludeItemTypes: z.string().optional(),
Fields: z.string().optional(),
ImageTypeLimit: z.number().optional(),
IncludeItemTypes: z.string().optional(),
IsFavorite: z.boolean().optional(),
Limit: z.number().optional(),
MediaTypes: 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(),
UserId: z.string().optional(),
});
const paginationParameters = z.object({
Limit: z.number().optional(),
NameStartsWith: z.string().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.string().optional(),
Logo: z.string().optional(),
Primary: 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 = 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({});
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,
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,
song,
songList,
topSongsList,
updatePlaylist,
user,
},
};
-617
View File
@@ -1,617 +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,
} from '/@/renderer/api/navidrome.types';
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import type {
Album,
Song,
AuthenticationResponse,
AlbumDetailArgs,
GenreListArgs,
AlbumListArgs,
AlbumArtistListArgs,
AlbumArtistDetailArgs,
SongListArgs,
SongDetailArgs,
CreatePlaylistArgs,
DeletePlaylistArgs,
PlaylistListArgs,
PlaylistDetailArgs,
CreatePlaylistResponse,
PlaylistSongListArgs,
AlbumArtist,
Playlist,
UpdatePlaylistResponse,
UpdatePlaylistArgs,
} from '/@/renderer/api/types';
import {
playlistListSortMap,
albumArtistListSortMap,
songListSortMap,
albumListSortMap,
sortOrderMap,
} 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';
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;
},
],
},
});
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 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 data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
signal,
})
.json<NDAlbumArtistDetailResponse>();
return { ...data };
};
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,
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 { query, server } = args;
const json: NDCreatePlaylistParams = {
comment: query.comment,
name: query.name,
public: query.public || false,
};
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: query.name,
};
};
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
const { query, body, server, signal } = args;
const json: NDUpdatePlaylistParams = {
comment: body.comment || '',
name: body.name,
public: body.public || false,
};
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] : NDSortOrder.ASC,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
_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 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;
// Dynamically determine the id field based on whether or not the item is a playlist song
if ('mediaFileId' in item) {
id = item.mediaFileId;
} else {
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server.url,
coverArtId: id,
credential: server.credential,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, name: item.artist }],
bitRate: item.bitRate,
bpm: item.bpm ? item.bpm : null,
channels: item.channels ? item.channels : 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,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate : null,
name: item.title,
note: item.comment ? item.comment : null,
path: item.path,
playCount: item.playCount,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
size: item.size,
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
trackNumber: item.trackNumber,
type: ServerType.NAVIDROME,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
};
};
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, name: item.albumArtist }],
artists: [{ id: item.artistId, 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,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
name: item.name,
playCount: item.playCount,
rating: item.rating,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
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,
};
};
const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: item.largeImageUrl,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
name: item.name,
playCount: item.playCount,
rating: item.rating,
songCount: item.songCount,
};
};
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,
name: item.name,
public: item.public,
rules: item?.rules || null,
size: item.size,
songCount: item.songCount,
userId: item.ownerId,
username: item.ownerName,
};
};
export const navidromeApi = {
authenticate,
createPlaylist,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getGenreList,
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getSongDetail,
getSongList,
updatePlaylist,
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
};
+131 -6
View File
@@ -1,3 +1,5 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = { export type NDAuthenticate = {
id: string; id: string;
isAdmin: boolean; isAdmin: boolean;
@@ -8,6 +10,18 @@ export type NDAuthenticate = {
username: string; username: string;
}; };
export type NDUser = {
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
};
export type NDGenre = { export type NDGenre = {
id: string; id: string;
name: string; name: string;
@@ -101,19 +115,21 @@ export type NDAlbumArtist = {
fullText: string; fullText: string;
genres: NDGenre[]; genres: NDGenre[];
id: string; id: string;
largeImageUrl: string; largeImageUrl?: string;
mbzArtistId: string; mbzArtistId: string;
mediumImageUrl: string; mediumImageUrl?: string;
name: string; name: string;
orderArtistName: string; orderArtistName: string;
playCount: number; playCount: number;
playDate: string; playDate: string;
rating: number; rating: number;
size: number; size: number;
smallImageUrl: string; smallImageUrl?: string;
songCount: number; songCount: number;
starred: boolean; starred: boolean;
starredAt: string; starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
}; };
export type NDAuthenticationResponse = NDAuthenticate; export type NDAuthenticationResponse = NDAuthenticate;
@@ -214,8 +230,8 @@ export type NDAlbumListParams = {
export enum NDSongListSort { export enum NDSongListSort {
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title', ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'discNumber, trackNumber', ALBUM_SONGS = 'album, discNumber, trackNumber',
ARTIST = 'artist', ARTIST = 'artist',
BPM = 'bpm', BPM = 'bpm',
CHANNELS = 'channels', CHANNELS = 'channels',
@@ -227,9 +243,10 @@ export enum NDSongListSort {
PLAY_COUNT = 'playCount', PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate', PLAY_DATE = 'playDate',
RATING = 'rating', RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title', TITLE = 'title',
TRACK = 'track', TRACK = 'track',
YEAR = 'year', YEAR = 'year, album, discNumber, trackNumber',
} }
export type NDSongListParams = { export type NDSongListParams = {
@@ -257,6 +274,26 @@ export type NDAlbumArtistListParams = {
} & NDPagination & } & NDPagination &
NDOrder; NDOrder;
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = { export type NDCreatePlaylistParams = {
comment?: string; comment?: string;
name: string; name: string;
@@ -339,3 +376,91 @@ export type NDPlaylistSongList = {
startIndex: number; startIndex: number;
totalRecordCount: number; totalRecordCount: number;
}; };
export const NDSongQueryFields = [
{ label: 'Album', type: 'string', value: 'album' },
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
{ label: 'BPM', type: 'number', value: 'bpm' },
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
{ label: 'Channels', type: 'number', value: 'channels' },
{ label: 'Comment', type: 'string', value: 'comment' },
{ label: 'Date Added', type: 'date', value: 'dateadded' },
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
{ label: 'Duration', type: 'number', value: 'duration' },
{ label: 'File Path', type: 'string', value: 'filepath' },
{ label: 'File Type', type: 'string', value: 'filetype' },
{ label: 'Genre', type: 'string', value: 'genre' },
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
{ label: 'Name', type: 'string', value: 'title' },
{ label: 'Play Count', type: 'number', value: 'playcount' },
{ label: 'Rating', type: 'number', value: 'rating' },
{ label: 'Size', type: 'number', value: 'size' },
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
{ label: 'Year', type: 'number', value: 'year' },
];
export const NDSongQueryDateOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'is before', value: 'before' },
{ label: 'is after', value: 'after' },
{ label: 'is in the last', value: 'inTheLast' },
{ label: 'is not in the last', value: 'notInTheLast' },
{ label: 'is in the range', value: 'inTheRange' },
];
export const NDSongQueryStringOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'starts with', value: 'startsWith' },
{ label: 'ends with', value: 'endsWith' },
];
export const NDSongQueryBooleanOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
];
export const NDSongQueryNumberOperators = [
{ label: 'is', value: 'is' },
{ label: 'is not', value: 'isNot' },
{ label: 'contains', value: 'contains' },
{ label: 'does not contain', value: 'notContains' },
{ label: 'is greater than', value: 'gt' },
{ label: 'is less than', value: 'lt' },
{ label: 'is in the range', value: 'inTheRange' },
];
export type NDUserListParams = {
_sort?: NDUserListSort;
} & NDPagination &
NDOrder;
export type NDUserListResponse = NDUser[];
export type NDUserList = {
items: NDUser[];
startIndex: number;
totalRecordCount: number;
};
export enum NDUserListSort {
NAME = 'name',
}
+271
View File
@@ -0,0 +1,271 @@
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);
const notNilParams = omitBy(parsedParams, (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,447 @@
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';
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,
},
});
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, 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_id: query.albumIds,
artist_id: query.artistIds,
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,228 @@
import { nanoid } from 'nanoid';
import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod';
import { ndType } from './navidrome-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>,
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: null,
// 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,358 @@
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(),
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_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(),
starred: z.boolean().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(),
});
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,
},
};
-223
View File
@@ -1,223 +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,
} from '/@/renderer/api/navidrome.types';
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
import type {
Album,
RawAlbumArtistListResponse,
RawAlbumDetailResponse,
RawAlbumListResponse,
RawGenreListResponse,
RawMusicFolderListResponse,
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
} 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 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 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));
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;
};
export const normalize = {
albumArtistList,
albumDetail,
albumList,
genreList,
musicFolderList,
playlistDetail,
playlistList,
songList,
};
+15 -1
View File
@@ -7,11 +7,14 @@ import type {
PlaylistListQuery, PlaylistListQuery,
PlaylistDetailQuery, PlaylistDetailQuery,
PlaylistSongListQuery, PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
} from './types'; } from './types';
export const queryKeys = { export const queryKeys = {
albumArtists: { albumArtists: {
detail: (serverId: string, query?: AlbumArtistListQuery) => { detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const; if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const; return [serverId, 'albumArtists', 'detail'] as const;
}, },
@@ -20,6 +23,10 @@ export const queryKeys = {
return [serverId, 'albumArtists', 'list'] as const; return [serverId, 'albumArtists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'albumArtists'] as const, root: (serverId: string) => [serverId, 'albumArtists'] as const,
topSongs: (serverId: string, query?: TopSongListQuery) => {
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
return [serverId, 'albumArtists', 'topSongs'] as const;
},
}, },
albums: { albums: {
detail: (serverId: string, query?: AlbumDetailQuery) => detail: (serverId: string, query?: AlbumDetailQuery) =>
@@ -79,4 +86,11 @@ export const queryKeys = {
}, },
root: (serverId: string) => [serverId, 'songs'] as const, root: (serverId: string) => [serverId, 'songs'] as const,
}, },
users: {
list: (serverId: string, query?: UserListQuery) => {
if (query) return [serverId, 'users', 'list', query] as const;
return [serverId, 'users', 'list'] as const;
},
root: (serverId: string) => [serverId, 'users'] as const,
},
}; };
-309
View File
@@ -1,309 +0,0 @@
import ky from 'ky';
import md5 from 'md5';
import { randomString } from '/@/renderer/utils';
import type {
SSAlbumListResponse,
SSAlbumDetailResponse,
SSArtistIndex,
SSAlbumArtistList,
SSAlbumArtistListResponse,
SSGenreListResponse,
SSMusicFolderList,
SSMusicFolderListResponse,
SSGenreList,
SSAlbumDetail,
SSAlbumList,
SSAlbumArtistDetail,
SSAlbumArtistDetailResponse,
SSFavoriteParams,
SSFavoriteResponse,
SSRatingParams,
SSRatingResponse,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
} from '/@/renderer/api/subsonic.types';
import type {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
MusicFolderListArgs,
RatingArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
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}/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') {
toast.warn({ message: 'Issue from Subsonic API' });
}
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
},
],
beforeRequest: [
(request) => {
const server = useAuthStore.getState().currentServer;
const searchParams = new URLSearchParams();
if (server) {
const authParams = server.credential.split(/&?\w=/gm);
searchParams.set('u', server.username);
searchParams.set('v', '1.13.0');
searchParams.set('c', 'Feishin');
searchParams.set('f', 'json');
if (authParams?.length === 4) {
searchParams.set('s', authParams[2]);
searchParams.set('t', authParams[3]);
} else if (authParams?.length === 3) {
searchParams.set('p', authParams[2]);
}
}
return ky(request, { searchParams });
},
],
},
});
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 data = await api
.get('rest/getMusicFolders.view', {
prefixUrl: server?.url,
signal,
})
.json<SSMusicFolderListResponse>();
return data.musicFolders.musicFolder;
};
export const getAlbumArtistDetail = async (
args: AlbumArtistDetailArgs,
): Promise<SSAlbumArtistDetail> => {
const { server, signal, query } = args;
const searchParams: SSAlbumArtistDetailParams = {
id: query.id,
};
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 searchParams: SSAlbumArtistListParams = {
musicFolderId: query.musicFolderId,
};
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 data = await api
.get('rest/getGenres.view', {
prefixUrl: server?.url,
signal,
})
.json<SSGenreListResponse>();
return data.genres.genre;
};
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
const { server, query, signal } = args;
const data = await api
.get('rest/getAlbum.view', {
prefixUrl: server?.url,
searchParams: { id: query.id },
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 normalizedParams = {};
const data = await api
.get('rest/getAlbumList2.view', {
prefixUrl: server?.url,
searchParams: normalizedParams,
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 searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
await api
.get('rest/star.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSFavoriteResponse>();
return {
id: query.id,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { server, query, signal } = args;
const searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
await api
.get('rest/unstar.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSFavoriteResponse>();
return {
id: query.id,
};
};
const updateRating = async (args: RatingArgs) => {
const { server, query, signal } = args;
const searchParams: SSRatingParams = {
id: query.id,
rating: query.rating,
};
const data = await api
.get('rest/setRating.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSRatingResponse>();
return data;
};
export const subsonicApi = {
authenticate,
createFavorite,
deleteFavorite,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
updateRating,
};
+36
View File
@@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
album: SSAlbum; album: SSAlbum;
}; };
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = { export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo; artistInfo2: SSArtistInfo;
}; };
@@ -75,6 +81,13 @@ export type SSArtistInfo = {
lastFmUrl?: string; lastFmUrl?: string;
mediumImageUrl?: string; mediumImageUrl?: string;
musicBrainzId?: string; musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string; smallImageUrl?: string;
}; };
@@ -186,3 +199,26 @@ export type SSRatingParams = {
export type SSRating = null; export type SSRating = null;
export type SSRatingResponse = null; export type SSRatingResponse = null;
export type SSTopSongListParams = {
artist: string;
count?: number;
};
export type SSTopSongListResponse = {
topSongs: {
song: SSSong[];
};
};
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};
+168
View File
@@ -0,0 +1,168 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
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,
},
},
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,
},
},
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);
},
);
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> = {};
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,
},
signal,
url: `${baseUrl}/${path}`,
});
return {
body: result.data['subsonic-response'],
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: '',
});
};
@@ -0,0 +1,317 @@
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,
} 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;
};
export const ssController = {
authenticate,
createFavorite,
getArtistInfo,
getMusicFolderList,
getTopSongList,
removeFavorite,
scrobble,
setRating,
};
@@ -0,0 +1,103 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem } 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 : 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 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,
};
};
export const ssNormalize = {
song: normalizeSong,
};
+201
View File
@@ -0,0 +1,201 @@
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({
artistInfo2: 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();
export const ssType = {
_parameters: {
albumList: albumListParameters,
artistInfo: artistInfoParameters,
authenticate: authenticateParameters,
createFavorite: createFavoriteParameters,
removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters,
setRating: setRatingParameters,
topSongsList: topSongsListParameters,
},
_response: {
albumArtistList,
albumList,
artistInfo,
authenticate,
baseResponse,
createFavorite,
musicFolderList,
removeFavorite,
scrobble,
setRating,
song,
topSongsList,
},
};
+288 -169
View File
@@ -1,53 +1,54 @@
import { import {
JFSortOrder, JFSortOrder,
JFGenreList,
JFAlbumList,
JFAlbumListSort, JFAlbumListSort,
JFAlbumDetail,
JFSongList,
JFSongListSort, JFSongListSort,
JFAlbumArtistList,
JFAlbumArtistListSort, JFAlbumArtistListSort,
JFAlbumArtistDetail,
JFArtistList,
JFArtistListSort, JFArtistListSort,
JFPlaylistList,
JFPlaylistDetail,
JFMusicFolderList,
JFPlaylistListSort, JFPlaylistListSort,
} from '/@/renderer/api/jellyfin.types'; } from '/@/renderer/api/jellyfin.types';
import { import {
NDSortOrder, NDSortOrder,
NDOrder, NDOrder,
NDGenreList,
NDAlbumList,
NDAlbumListSort, NDAlbumListSort,
NDAlbumDetail,
NDSongList,
NDSongDetail,
NDAlbumArtistList,
NDAlbumArtistListSort, NDAlbumArtistListSort,
NDAlbumArtistDetail,
NDDeletePlaylist,
NDPlaylistList,
NDPlaylistListSort, NDPlaylistListSort,
NDPlaylistDetail,
NDSongListSort, NDSongListSort,
NDUserListSort,
} from '/@/renderer/api/navidrome.types'; } from '/@/renderer/api/navidrome.types';
import {
SSAlbumList, export enum LibraryItem {
SSAlbumDetail, ALBUM = 'album',
SSAlbumArtistList, ALBUM_ARTIST = 'albumArtist',
SSAlbumArtistDetail, ARTIST = 'artist',
SSMusicFolderList, PLAYLIST = 'playlist',
SSGenreList, SONG = 'song',
} from '/@/renderer/api/subsonic.types'; }
export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong;
export type AnyLibraryItems =
| Album[]
| AlbumArtist[]
| Artist[]
| Playlist[]
| Song[]
| QueueSong[];
export enum SortOrder { export enum SortOrder {
ASC = 'ASC', ASC = 'ASC',
DESC = 'DESC', DESC = 'DESC',
} }
export type User = {
createdAt: string | null;
email: string | null;
id: string;
isAdmin: boolean | null;
lastLoginAt: string | null;
name: string;
updatedAt: string | null;
};
export type ServerListItem = { export type ServerListItem = {
credential: string; credential: string;
id: string; id: string;
@@ -128,8 +129,10 @@ export type AuthenticationResponse = {
}; };
export type Genre = { export type Genre = {
albumCount?: number;
id: string; id: string;
name: string; name: string;
songCount?: number;
}; };
export type Album = { export type Album = {
@@ -143,19 +146,21 @@ export type Album = {
imagePlaceholderUrl: string | null; imagePlaceholderUrl: string | null;
imageUrl: string | null; imageUrl: string | null;
isCompilation: boolean | null; isCompilation: boolean | null;
isFavorite: boolean; itemType: LibraryItem.ALBUM;
lastPlayedAt: string | null; lastPlayedAt: string | null;
name: string; name: string;
playCount: number | null; playCount: number | null;
rating: number | null;
releaseDate: string | null; releaseDate: string | null;
releaseYear: number | null; releaseYear: number | null;
serverId: string;
serverType: ServerType; serverType: ServerType;
size: number | null; size: number | null;
songCount: number | null; songCount: number | null;
songs?: Song[]; songs?: Song[];
uniqueId: string; uniqueId: string;
updatedAt: string; updatedAt: string;
userFavorite: boolean;
userRating: number | null;
} & { songs?: Song[] }; } & { songs?: Song[] };
export type Song = { export type Song = {
@@ -167,6 +172,7 @@ export type Song = {
bitRate: number; bitRate: number;
bpm: number | null; bpm: number | null;
channels: number | null; channels: number | null;
comment: string | null;
compilation: boolean | null; compilation: boolean | null;
container: string | null; container: string | null;
createdAt: string; createdAt: string;
@@ -176,21 +182,23 @@ export type Song = {
id: string; id: string;
imagePlaceholderUrl: string | null; imagePlaceholderUrl: string | null;
imageUrl: string | null; imageUrl: string | null;
isFavorite: boolean; itemType: LibraryItem.SONG;
lastPlayedAt: string | null; lastPlayedAt: string | null;
name: string; name: string;
note: string | null;
path: string | null; path: string | null;
playCount: number; playCount: number;
playlistItemId?: string;
releaseDate: string | null; releaseDate: string | null;
releaseYear: string | null; releaseYear: string | null;
serverId: string; serverId: string;
serverType: ServerType;
size: number; size: number;
streamUrl: string; streamUrl: string;
trackNumber: number; trackNumber: number;
type: ServerType;
uniqueId: string; uniqueId: string;
updatedAt: string; updatedAt: string;
userFavorite: boolean;
userRating: number | null;
}; };
export type AlbumArtist = { export type AlbumArtist = {
@@ -201,12 +209,16 @@ export type AlbumArtist = {
genres: Genre[]; genres: Genre[];
id: string; id: string;
imageUrl: string | null; imageUrl: string | null;
isFavorite: boolean; itemType: LibraryItem.ALBUM_ARTIST;
lastPlayedAt: string | null; lastPlayedAt: string | null;
name: string; name: string;
playCount: number | null; playCount: number | null;
rating: number | null; serverId: string;
serverType: ServerType;
similarArtists: RelatedArtist[] | null;
songCount: number | null; songCount: number | null;
userFavorite: boolean;
userRating: number | null;
}; };
export type RelatedAlbumArtist = { export type RelatedAlbumArtist = {
@@ -218,14 +230,18 @@ export type Artist = {
biography: string | null; biography: string | null;
createdAt: string; createdAt: string;
id: string; id: string;
itemType: LibraryItem.ARTIST;
name: string; name: string;
remoteCreatedAt: string | null; remoteCreatedAt: string | null;
serverFolderId: string; serverFolderId: string;
serverId: string;
serverType: ServerType;
updatedAt: string; updatedAt: string;
}; };
export type RelatedArtist = { export type RelatedArtist = {
id: string; id: string;
imageUrl: string | null;
name: string; name: string;
}; };
@@ -241,13 +257,17 @@ export type Playlist = {
id: string; id: string;
imagePlaceholderUrl: string | null; imagePlaceholderUrl: string | null;
imageUrl: string | null; imageUrl: string | null;
itemType: LibraryItem.PLAYLIST;
name: string; name: string;
owner: string | null;
ownerId: string | null;
public: boolean | null; public: boolean | null;
rules?: Record<string, any> | null; rules?: Record<string, any> | null;
serverId: string;
serverType: ServerType;
size: number | null; size: number | null;
songCount: number | null; songCount: number | null;
userId: string | null; sync?: boolean | null;
username: string | null;
}; };
export type GenresResponse = Genre[]; export type GenresResponse = Genre[];
@@ -257,13 +277,13 @@ export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder; export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = { type BaseEndpointArgs = {
server: ServerListItem | null; apiClientProps: {
signal?: AbortSignal; server: ServerListItem | null;
signal?: AbortSignal;
};
}; };
// Genre List // Genre List
export type RawGenreListResponse = NDGenreList | JFGenreList | SSGenreList | undefined;
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined; export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
@@ -271,8 +291,6 @@ export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export type GenreListQuery = null; export type GenreListQuery = null;
// Album List // Album List
export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined;
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined; export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
export enum AlbumListSort { export enum AlbumListSort {
@@ -294,29 +312,33 @@ export enum AlbumListSort {
} }
export type AlbumListQuery = { export type AlbumListQuery = {
jfParams?: { _custom?: {
albumArtistIds?: string; jellyfin?: {
artistIds?: string; albumArtistIds?: string;
filters?: string; artistIds?: string;
genreIds?: string; contributingArtistIds?: string;
genres?: string; filters?: string;
isFavorite?: boolean; genreIds?: string;
maxYear?: number; // Parses to years genres?: string;
minYear?: number; // Parses to years isFavorite?: boolean;
tags?: string; maxYear?: number; // Parses to years
minYear?: number; // Parses to years
tags?: string;
};
navidrome?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
};
}; };
artistIds?: string[];
limit?: number; limit?: number;
musicFolderId?: string; 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; searchTerm?: string;
sortBy: AlbumListSort; sortBy: AlbumListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
@@ -386,8 +408,6 @@ export const albumListSortMap: AlbumListSortMap = {
}; };
// Album Detail // Album Detail
export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined;
export type AlbumDetailResponse = Album | null | undefined; export type AlbumDetailResponse = Album | null | undefined;
export type AlbumDetailQuery = { id: string }; export type AlbumDetailQuery = { id: string };
@@ -395,8 +415,6 @@ export type AlbumDetailQuery = { id: string };
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs; export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
// Song List // Song List
export type RawSongListResponse = NDSongList | JFSongList | undefined;
export type SongListResponse = BasePaginatedResponse<Song[]>; export type SongListResponse = BasePaginatedResponse<Song[]>;
export enum SongListSort { export enum SongListSort {
@@ -421,31 +439,35 @@ export enum SongListSort {
} }
export type SongListQuery = { export type SongListQuery = {
_custom?: {
jellyfin?: {
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;
};
navidrome?: {
album_id?: string[];
artist_id?: string[];
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
starred?: boolean;
title?: string;
year?: number;
};
};
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
jfParams?: {
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; limit?: number;
musicFolderId?: string; 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; searchTerm?: string;
sortBy: SongListSort; sortBy: SongListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
@@ -496,7 +518,7 @@ export const songListSortMap: SongListSortMap = {
playCount: NDSongListSort.PLAY_COUNT, playCount: NDSongListSort.PLAY_COUNT,
random: undefined, random: undefined,
rating: NDSongListSort.RATING, rating: NDSongListSort.RATING,
recentlyAdded: NDSongListSort.PLAY_DATE, recentlyAdded: NDSongListSort.RECENTLY_ADDED,
recentlyPlayed: NDSongListSort.PLAY_DATE, recentlyPlayed: NDSongListSort.PLAY_DATE,
releaseDate: undefined, releaseDate: undefined,
year: NDSongListSort.YEAR, year: NDSongListSort.YEAR,
@@ -524,8 +546,6 @@ export const songListSortMap: SongListSortMap = {
}; };
// Song Detail // Song Detail
export type RawSongDetailResponse = NDSongDetail | undefined;
export type SongDetailResponse = Song | null | undefined; export type SongDetailResponse = Song | null | undefined;
export type SongDetailQuery = { id: string }; export type SongDetailQuery = { id: string };
@@ -533,13 +553,7 @@ export type SongDetailQuery = { id: string };
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs; export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
// Album Artist List // Album Artist List
export type RawAlbumArtistListResponse = export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
| NDAlbumArtistList
| SSAlbumArtistList
| JFAlbumArtistList
| undefined;
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;
export enum AlbumArtistListSort { export enum AlbumArtistListSort {
ALBUM = 'album', ALBUM = 'album',
@@ -556,13 +570,15 @@ export enum AlbumArtistListSort {
} }
export type AlbumArtistListQuery = { export type AlbumArtistListQuery = {
_custom?: {
navidrome?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
};
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
searchTerm?: string; searchTerm?: string;
sortBy: AlbumArtistListSort; sortBy: AlbumArtistListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
@@ -620,21 +636,14 @@ export const albumArtistListSortMap: AlbumArtistListSortMap = {
}; };
// Album Artist Detail // 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 AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs; export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
// Artist List // Artist List
export type RawArtistListResponse = JFArtistList | undefined;
export type ArtistListResponse = BasePaginatedResponse<Artist[]>; export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
export enum ArtistListSort { export enum ArtistListSort {
@@ -652,13 +661,15 @@ export enum ArtistListSort {
} }
export type ArtistListQuery = { export type ArtistListQuery = {
_custom?: {
navidrome?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
};
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
ndParams?: {
genre_id?: string;
name?: string;
starred?: boolean;
};
sortBy: ArtistListSort; sortBy: ArtistListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
startIndex: number; startIndex: number;
@@ -717,71 +728,113 @@ export const artistListSortMap: ArtistListSortMap = {
// Artist Detail // Artist Detail
// Favorite // Favorite
export type RawFavoriteResponse = FavoriteResponse | undefined; export type FavoriteResponse = null | undefined;
export type FavoriteResponse = { id: string }; export type FavoriteQuery = {
id: string[];
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' }; type: LibraryItem;
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
// Rating
export type RawRatingResponse = null | undefined;
export type RatingResponse = null;
export type RatingQuery = { id: string; rating: number };
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
export type CreatePlaylistResponse = { id: string; name: string };
export type CreatePlaylistQuery = {
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any>;
}; };
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs; export type FavoriteArgs = { query: FavoriteQuery; serverId?: string } & BaseEndpointArgs;
// Rating
export type RatingResponse = null | undefined;
export type RatingQuery = {
item: AnyLibraryItems;
rating: number;
};
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
// Add to playlist
export type AddToPlaylistResponse = null | undefined;
export type AddToPlaylistQuery = {
id: string;
};
export type AddToPlaylistBody = {
songId: string[];
};
export type AddToPlaylistArgs = {
body: AddToPlaylistBody;
query: AddToPlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Remove from playlist
export type RemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistQuery = {
id: string;
songId: string[];
};
export type RemoveFromPlaylistArgs = {
query: RemoveFromPlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Create Playlist
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;
};
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
// Update Playlist // Update Playlist
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined; export type UpdatePlaylistResponse = null | undefined;
export type UpdatePlaylistResponse = { id: string };
export type UpdatePlaylistQuery = { export type UpdatePlaylistQuery = {
id: string; id: string;
}; };
export type UpdatePlaylistBody = { export type UpdatePlaylistBody = {
_custom?: {
navidrome?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
comment?: string; comment?: string;
genres?: Genre[]; genres?: Genre[];
name: string; name: string;
public?: boolean;
rules?: Record<string, any>;
}; };
export type UpdatePlaylistArgs = { export type UpdatePlaylistArgs = {
body: UpdatePlaylistBody; body: UpdatePlaylistBody;
query: UpdatePlaylistQuery; query: UpdatePlaylistQuery;
serverId?: string;
} & BaseEndpointArgs; } & BaseEndpointArgs;
// Delete Playlist // Delete Playlist
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined; export type DeletePlaylistResponse = null | undefined;
export type DeletePlaylistResponse = null;
export type DeletePlaylistQuery = { id: string }; export type DeletePlaylistQuery = { id: string };
export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs; export type DeletePlaylistArgs = {
query: DeletePlaylistQuery;
serverId?: string;
} & BaseEndpointArgs;
// Playlist List // Playlist List
export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined;
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>; export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export enum PlaylistListSort { export enum PlaylistListSort {
@@ -794,10 +847,13 @@ export enum PlaylistListSort {
} }
export type PlaylistListQuery = { export type PlaylistListQuery = {
limit?: number; _custom?: {
ndParams?: { navidrome?: {
owner_id?: string; owner_id?: string;
smart?: boolean;
};
}; };
limit?: number;
searchTerm?: string; searchTerm?: string;
sortBy: PlaylistListSort; sortBy: PlaylistListSort;
sortOrder: SortOrder; sortOrder: SortOrder;
@@ -840,9 +896,7 @@ export const playlistListSortMap: PlaylistListSortMap = {
}; };
// Playlist Detail // Playlist Detail
export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined; export type PlaylistDetailResponse = Playlist;
export type PlaylistDetailResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistDetailQuery = { export type PlaylistDetailQuery = {
id: string; id: string;
@@ -851,8 +905,6 @@ export type PlaylistDetailQuery = {
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs; export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
// Playlist Songs // Playlist Songs
export type RawPlaylistSongListResponse = JFSongList | undefined;
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>; export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
export type PlaylistSongListQuery = { export type PlaylistSongListQuery = {
@@ -866,17 +918,84 @@ export type PlaylistSongListQuery = {
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
// Music Folder List // 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; export type MusicFolderListArgs = BaseEndpointArgs;
// Create Favorite // User list
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined; // Playlist List
export type UserListResponse = BasePaginatedResponse<User[]>;
export type CreateFavoriteResponse = { id: string }; export enum UserListSort {
NAME = 'name',
}
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean }; export type UserListQuery = {
_custom?: {
navidrome?: {
owner_id?: string;
};
};
limit?: number;
searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
startIndex: number;
};
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs; export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
type UserListSortMap = {
jellyfin: Record<UserListSort, undefined>;
navidrome: Record<UserListSort, NDUserListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
};
export const userListSortMap: UserListSortMap = {
jellyfin: {
name: undefined,
},
navidrome: {
name: NDUserListSort.NAME,
},
subsonic: {
name: undefined,
},
};
// Top Songs List
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
export type TopSongListQuery = {
artist: string;
artistId: string;
limit?: number;
};
export type TopSongListArgs = { query: TopSongListQuery } & BaseEndpointArgs;
// Artist Info
export type ArtistInfoQuery = {
artistId: string;
limit: number;
musicFolderId?: string;
};
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
// Scrobble
export type ScrobbleResponse = null | undefined;
export type ScrobbleArgs = {
query: ScrobbleQuery;
serverId?: string;
} & BaseEndpointArgs;
export type ScrobbleQuery = {
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
id: string;
position?: number;
submission: boolean;
};
+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),
});
};
+73 -49
View File
@@ -4,7 +4,6 @@ import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { initSimpleImg } from 'react-simple-img'; import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal } from './components'; import { BaseContextModal } from './components';
import { useTheme } from './hooks'; import { useTheme } from './hooks';
@@ -15,11 +14,17 @@ import '@ag-grid-community/styles/ag-grid.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu'; import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlayQueueHandlerContext } from '/@/renderer/features/player'; 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';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
initSimpleImg({ threshold: 0.05 }, true); initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export const App = () => { export const App = () => {
const theme = useTheme(); const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent); const contentFont = useSettingsStore((state) => state.general.fontContent);
@@ -31,20 +36,41 @@ export const App = () => {
root.style.setProperty('--content-font-family', contentFont); root.style.setProperty('--content-font-family', contentFont);
}, [contentFont]); }, [contentFont]);
// Start the mpv instance on startup
useEffect(() => {
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties = {
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
volume: usePlayerStore.getState().volume,
};
mpvPlayer?.restart({
extraParameters,
properties,
});
}, []);
return ( return (
<MantineProvider <MantineProvider
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
theme={{ theme={{
breakpoints: {
lg: 1200,
md: 1000,
sm: 800,
xl: 1400,
xs: 500,
},
colorScheme: theme as 'light' | 'dark', colorScheme: theme as 'light' | 'dark',
components: { Modal: { styles: { body: { padding: '.5rem' } } } }, components: {
Modal: {
styles: {
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
close: { marginRight: '0.5rem' },
content: { borderRadius: '10px' },
header: {
background: 'var(--modal-bg)',
borderBottom: '1px solid var(--generic-border-color)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 'bold' },
},
},
},
defaultRadius: 'xs', defaultRadius: 'xs',
dir: 'ltr', dir: 'ltr',
focusRing: 'auto', focusRing: 'auto',
@@ -60,53 +86,51 @@ export const App = () => {
}, },
fontFamily: 'var(--content-font-family)', fontFamily: 'var(--content-font-family)',
fontSizes: { fontSizes: {
lg: 16, lg: '1.1rem',
md: 14, md: '1rem',
sm: 12, sm: '0.9rem',
xl: 18, xl: '1.5rem',
xs: 10, xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
sizes: {
h1: '6rem',
h2: '4rem',
h3: '3rem',
h4: '1.5rem',
h5: '1.2rem',
h6: '1rem',
},
}, },
headings: { fontFamily: 'var(--content-font-family)' },
other: {}, other: {},
spacing: { spacing: {
lg: 12, lg: '2rem',
md: 8, md: '1rem',
sm: 4, sm: '0.5rem',
xl: 16, xl: '4rem',
xs: 2, xs: '0rem',
}, },
}} }}
> >
<NotificationsProvider <ModalsProvider
autoClose={1500} modalProps={{
position="bottom-center" centered: true,
style={{ transitionProps: {
marginBottom: '85px', duration: 300,
opacity: '.8', exitDuration: 300,
userSelect: 'none',
width: '300px',
}}
transitionDuration={200}
>
<ModalsProvider
modalProps={{
centered: true,
exitTransitionDuration: 300,
overflow: 'inside',
overlayBlur: 0,
overlayOpacity: 0.8,
transition: 'slide-down', transition: 'slide-down',
transitionDuration: 300, },
}} }}
modals={{ base: BaseContextModal }} modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
> >
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}> <PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider> <ContextMenuProvider>
<AppRouter /> <AppRouter />
</ContextMenuProvider> </ContextMenuProvider>
</PlayQueueHandlerContext.Provider> </PlayQueueHandlerContext.Provider>
</ModalsProvider> </ModalsProvider>
</NotificationsProvider>
</MantineProvider> </MantineProvider>
); );
}; };
@@ -52,7 +52,7 @@ export const AudioPlayer = forwardRef(
const player1Ref = useRef<any>(null); const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null); const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.player.audioDeviceId); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
get player1() { get player1() {
+2 -1
View File
@@ -5,12 +5,13 @@ import styled from 'styled-components';
export type BadgeProps = MantineBadgeProps; export type BadgeProps = MantineBadgeProps;
const StyledBadge = styled(MantineBadge)<BadgeProps>` const StyledBadge = styled(MantineBadge)<BadgeProps>`
border-radius: var(--badge-radius);
.mantine-Badge-root { .mantine-Badge-root {
color: var(--badge-fg); color: var(--badge-fg);
} }
.mantine-Badge-inner { .mantine-Badge-inner {
padding: 0 0.5rem;
color: var(--badge-fg); color: var(--badge-fg);
} }
`; `;
+14 -123
View File
@@ -20,145 +20,36 @@ interface StyledButtonProps extends ButtonProps {
} }
const StyledButton = styled(MantineButton)<StyledButtonProps>` const StyledButton = styled(MantineButton)<StyledButtonProps>`
color: ${(props) => { color: ${(props) => `var(--btn-${props.variant}-fg)`};
switch (props.variant) { background: ${(props) => `var(--btn-${props.variant}-bg)`};
case 'default': border: ${(props) => `var(--btn-${props.variant}-border)`};
return 'var(--btn-default-fg)'; border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg)';
case 'filled':
return 'var(--btn-primary-bg)';
case 'subtle':
return 'var(--btn-subtle-bg)';
default:
return '';
}
}};
border: none;
transition: background 0.2s ease-in-out, color 0.2s ease-in-out; transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
svg { svg {
transition: fill 0.2s ease-in-out; transition: fill 0.2s ease-in-out;
fill: ${(props) => { fill: ${(props) => `var(--btn-${props.variant}-fg)`};
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg)';
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
} }
&:disabled { &:disabled {
color: ${(props) => { color: ${(props) => `var(--btn-${props.variant}-fg)`};
switch (props.variant) { background: ${(props) => `var(--btn-${props.variant}-bg)`};
case 'default':
return 'var(--btn-default-fg)';
case 'filled':
return 'var(--btn-primary-fg)';
case 'subtle':
return 'var(--btn-subtle-fg)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg)';
case 'filled':
return 'var(--btn-primary-bg)';
case 'subtle':
return 'var(--btn-subtle-bg)';
default:
return '';
}
}};
opacity: 0.6; opacity: 0.6;
} }
&:hover { &:not([data-disabled])&:hover {
color: ${(props) => { color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
switch (props.variant) { background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg-hover)';
case 'filled':
return 'var(--btn-primary-bg-hover)';
case 'subtle':
return 'var(--btn-subtle-bg-hover)';
default:
return '';
}
}};
svg { svg {
fill: ${(props) => { fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
switch (props.variant) {
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
} }
} }
&:focus-visible { &:not([data-disabled])&:focus-visible {
color: ${(props) => { color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
switch (props.variant) { background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
case 'default':
return 'var(--btn-default-fg-hover)';
case 'filled':
return 'var(--btn-primary-fg-hover)';
case 'subtle':
return 'var(--btn-subtle-fg-hover)';
default:
return '';
}
}};
background: ${(props) => {
switch (props.variant) {
case 'default':
return 'var(--btn-default-bg-hover)';
case 'filled':
return 'var(--btn-primary-bg-hover)';
case 'subtle':
return 'var(--btn-subtle-bg-hover)';
default:
return '';
}
}};
} }
&:active { &:active {
+9 -104
View File
@@ -1,15 +1,14 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { Center } from '@mantine/core'; import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri'; import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router'; import { generatePath, useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img'; import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components'; import styled from 'styled-components';
import { Text } from '/@/renderer/components/text'; import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import { Skeleton } from '/@/renderer/components/skeleton'; import { Skeleton } from '/@/renderer/components/skeleton';
import { CardControls } from '/@/renderer/components/card/card-controls'; import { CardControls } from '/@/renderer/components/card/card-controls';
import { Album } from '/@/renderer/api/types'; import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card/card-rows';
const CardWrapper = styled.div<{ const CardWrapper = styled.div<{
link?: boolean; link?: boolean;
@@ -103,7 +102,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
interface BaseGridCardProps { interface BaseGridCardProps {
controls: { controls: {
cardRows: CardRow<Album>[]; cardRows: CardRow<Album | Artist | AlbumArtist>[];
itemType: LibraryItem; itemType: LibraryItem;
playButtonBehavior: Play; playButtonBehavior: Play;
route: CardRoute; route: CardRoute;
@@ -179,104 +178,10 @@ export const AlbumCard = ({
</ControlsContainer> </ControlsContainer>
</ImageSection> </ImageSection>
<DetailSection> <DetailSection>
{cardRows.map((row: CardRow<Album>, index: number) => { <CardRows
if (row.arrayProperty && row.route) { data={data}
return ( rows={cardRows}
<Row />
key={`row-${row.property}-${index}`}
$secondary={index > 0}
>
{data[row.property].map((item: any, itemIndex: number) => (
<React.Fragment key={`${data.id}-${item.id}`}>
{itemIndex > 0 && (
<Text
$noSelect
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
$link
$noSelect
$secondary={index > 0}
component={Link}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</React.Fragment>
))}
</Row>
);
}
if (row.arrayProperty) {
return (
<Row key={`row-${row.property}`}>
{data[row.property].map((item: any) => (
<Text
key={`${data.id}-${item.id}`}
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={`row-${row.property}`}>
{row.route ? (
<Text
$link
$noSelect
component={Link}
overflow="hidden"
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
onClick={(e) => e.stopPropagation()}
>
{data && data[row.property]}
</Text>
) : (
<Text
$noSelect
$secondary={index > 0}
overflow="hidden"
size={index > 0 ? 'xs' : 'md'}
>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</DetailSection> </DetailSection>
</StyledCard> </StyledCard>
</CardWrapper> </CardWrapper>
+28 -51
View File
@@ -5,10 +5,15 @@ import { Group } from '@mantine/core';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri'; import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button'; import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import type { PlayQueueAddOptions } from '/@/renderer/types';
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import {
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
@@ -98,21 +103,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
} }
`; `;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Add to queue (last)',
play: Play.LAST,
},
{
label: 'Add to queue (next)',
play: Play.NEXT,
},
];
export const CardControls = ({ export const CardControls = ({
itemData, itemData,
itemType, itemType,
@@ -122,7 +112,7 @@ export const CardControls = ({
itemData: any; itemData: any;
itemType: LibraryItem; itemType: LibraryItem;
}) => { }) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior); const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => { const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault(); e.preventDefault();
@@ -136,6 +126,11 @@ export const CardControls = ({
}); });
}; };
const handleContextMenu = useHandleGeneralContextMenu(
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
return ( return (
<GridCardControlsContainer> <GridCardControlsContainer>
<BottomControls> <BottomControls>
@@ -160,39 +155,21 @@ export const CardControls = ({
)} )}
</FavoriteWrapper> </FavoriteWrapper>
</SecondaryButton> </SecondaryButton>
<DropdownMenu <SecondaryButton
withinPortal p={5}
position="bottom-start" sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
> >
<DropdownMenu.Target> <RiMore2Fill
<SecondaryButton color="white"
p={5} size={20}
sx={{ svg: { fill: 'white !important' } }} />
variant="subtle" </SecondaryButton>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group> </Group>
</BottomControls> </BottomControls>
</GridCardControlsContainer> </GridCardControlsContainer>
+7 -6
View File
@@ -39,6 +39,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
{itemIndex > 0 && ( {itemIndex > 0 && (
<Text <Text
$noSelect $noSelect
$secondary
sx={{ sx={{
display: 'inline-block', display: 'inline-block',
padding: '0 2px 0 1px', padding: '0 2px 0 1px',
@@ -59,7 +60,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
row.route!.slugs?.reduce((acc, slug) => { row.route!.slugs?.reduce((acc, slug) => {
return { return {
...acc, ...acc,
[slug.slugProperty]: data[slug.idProperty], [slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
}; };
}, {}), }, {}),
)} )}
@@ -134,7 +135,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
arrayProperty: 'name', arrayProperty: 'name',
property: 'albumArtists', property: 'albumArtists',
route: { route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}, },
}, },
@@ -142,7 +143,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
arrayProperty: 'name', arrayProperty: 'name',
property: 'artists', property: 'artists',
route: { route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}, },
}, },
@@ -166,7 +167,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
property: 'playCount', property: 'playCount',
}, },
rating: { rating: {
property: 'rating', property: 'userRating',
}, },
releaseDate: { releaseDate: {
property: 'releaseDate', property: 'releaseDate',
@@ -195,7 +196,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
name: { name: {
property: 'name', property: 'name',
route: { route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }], slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
}, },
}, },
@@ -203,7 +204,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'playCount', property: 'playCount',
}, },
rating: { rating: {
property: 'rating', property: 'userRating',
}, },
songCount: { songCount: {
property: 'songCount', property: 'songCount',
+87 -18
View File
@@ -1,8 +1,7 @@
import { forwardRef, ReactNode, Ref } from 'react'; import { forwardRef, ReactNode, Ref } from 'react';
import { Portal } from '@mantine/core'; import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
import { motion } from 'framer-motion'; import { motion, Variants } from 'framer-motion';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
interface ContextMenuProps { interface ContextMenuProps {
children: ReactNode; children: ReactNode;
@@ -19,40 +18,110 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
z-index: 1000; z-index: 1000;
min-width: ${({ minWidth }) => minWidth}px; min-width: ${({ minWidth }) => minWidth}px;
max-width: ${({ maxWidth }) => maxWidth}px; max-width: ${({ maxWidth }) => maxWidth}px;
padding: 0.5rem;
background: var(--dropdown-menu-bg); background: var(--dropdown-menu-bg);
border-radius: 5px; border-radius: var(--dropdown-menu-border-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%); box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
button:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
border-top-right-radius: var(--dropdown-menu-border-radius);
}
button:last-child {
border-bottom-right-radius: var(--dropdown-menu-border-radius);
border-bottom-left-radius: var(--dropdown-menu-border-radius);
}
`; `;
export const ContextMenuButton = styled(_Button)` export const StyledContextMenuButton = styled(UnstyledButton)`
padding: 0.5rem; padding: var(--dropdown-menu-item-padding);
color: var(--dropdown-menu-fg);
font-weight: 500;
font-family: var(--content-font-family);
text-align: left;
background: var(--dropdown-menu-bg); background: var(--dropdown-menu-bg);
border: none;
cursor: default; cursor: default;
& .mantine-Button-inner { & .mantine-Button-inner {
justify-content: flex-start; justify-content: flex-start;
} }
&:hover {
background: var(--dropdown-menu-bg-hover);
}
&:disabled { &:disabled {
background: transparent; background: transparent;
opacity: 0.6;
} }
`; `;
export const ContextMenuButton = forwardRef(
(
{
children,
rightIcon,
leftIcon,
...props
}: UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'> & {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
},
ref: any,
) => {
return (
<StyledContextMenuButton
{...props}
key={props.key}
ref={ref}
as="button"
disabled={props.disabled}
onClick={props.onClick}
>
<Group position="apart">
<Group spacing="md">
<Box>{leftIcon}</Box>
<Box mr="2rem">{children}</Box>
</Group>
<Box>{rightIcon}</Box>
</Group>
</StyledContextMenuButton>
);
},
);
const variants: Variants = {
closed: {
opacity: 0,
transition: {
duration: 0.1,
},
},
open: {
opacity: 1,
transition: {
duration: 0.1,
},
},
};
export const ContextMenu = forwardRef( export const ContextMenu = forwardRef(
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => { ({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
return ( return (
<Portal> <ContextMenuContainer
<ContextMenuContainer ref={ref}
ref={ref} animate="open"
maxWidth={maxWidth} initial="closed"
minWidth={minWidth} maxWidth={maxWidth}
xPos={xPos} minWidth={minWidth}
yPos={yPos} variants={variants}
> xPos={xPos}
{children} yPos={yPos}
</ContextMenuContainer> >
</Portal> {children}
</ContextMenuContainer>
); );
}, },
); );
@@ -37,7 +37,6 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => { export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
return ( return (
<StyledDatePicker <StyledDatePicker
withinPortal
{...props} {...props}
sx={{ maxWidth, width }} sx={{ maxWidth, width }}
/> />
+43 -23
View File
@@ -6,12 +6,12 @@ import type {
MenuDropdownProps as MantineMenuDropdownProps, MenuDropdownProps as MantineMenuDropdownProps,
} from '@mantine/core'; } from '@mantine/core';
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core'; import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
import { RiArrowLeftSFill } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
type MenuProps = MantineMenuProps; type MenuProps = MantineMenuProps;
type MenuLabelProps = MantineMenuLabelProps; type MenuLabelProps = MantineMenuLabelProps;
interface MenuItemProps extends MantineMenuItemProps { interface MenuItemProps extends MantineMenuItemProps {
$danger?: boolean;
$isActive?: boolean; $isActive?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -21,17 +21,32 @@ type MenuDropdownProps = MantineMenuDropdownProps;
const StyledMenu = styled(MantineMenu)<MenuProps>``; const StyledMenu = styled(MantineMenu)<MenuProps>``;
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>` const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
padding: 0.5rem;
font-family: var(--content-font-family); font-family: var(--content-font-family);
`; `;
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>` const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
padding: 0.8rem; position: relative;
font-size: 0.9em; padding: var(--dropdown-menu-item-padding);
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family); font-family: var(--content-font-family);
background-color: ${({ $isActive }) => {
if (!$isActive) return undefined; ${(props) =>
return 'var(--dropdown-menu-bg-hover)'; 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 { &:disabled {
opacity: 0.6; opacity: 0.6;
@@ -41,44 +56,48 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
background-color: var(--dropdown-menu-bg-hover); background-color: var(--dropdown-menu-bg-hover);
} }
& .mantine-Menu-itemIcon {
margin-right: 0.5rem;
}
& .mantine-Menu-itemLabel { & .mantine-Menu-itemLabel {
color: var(--dropdown-menu-fg); margin-right: 2rem;
font-weight: 500; margin-left: 1rem;
font-size: 1em; color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
} }
& .mantine-Menu-itemRightSection { cursor: default;
display: flex;
margin-left: 2rem !important;
}
`; `;
const StyledMenuDropdown = styled(MantineMenu.Dropdown)` const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
margin: 0;
padding: 0;
background: var(--dropdown-menu-bg); background: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border); border: var(--dropdown-menu-border);
border-radius: var(--dropdown-menu-border-radius); border-radius: var(--dropdown-menu-border-radius);
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%)); filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
/* *:first-child {
border-top-left-radius: var(--dropdown-menu-border-radius);
border-top-right-radius: var(--dropdown-menu-border-radius);
}
*:last-child {
border-bottom-right-radius: var(--dropdown-menu-border-radius);
border-bottom-left-radius: var(--dropdown-menu-border-radius);
} */
`; `;
const StyledMenuDivider = styled(MantineMenu.Divider)` const StyledMenuDivider = styled(MantineMenu.Divider)`
margin: 0.3rem 0; margin: 0;
padding: 0;
`; `;
export const DropdownMenu = ({ children, ...props }: MenuProps) => { export const DropdownMenu = ({ children, ...props }: MenuProps) => {
return ( return (
<StyledMenu <StyledMenu
withinPortal withinPortal
radius="sm"
styles={{ styles={{
dropdown: { dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))', filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
}, },
}} }}
transition="scale-y"
{...props} {...props}
> >
{children} {children}
@@ -90,11 +109,12 @@ const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>; return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
}; };
const pMenuItem = ({ $isActive, children, ...props }: MenuItemProps) => { const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
return ( return (
<StyledMenuItem <StyledMenuItem
$danger={$danger}
$isActive={$isActive} $isActive={$isActive}
rightSection={$isActive && <RiArrowLeftSFill size={20} />} // rightSection={$isActive && <RiArrowLeftSFill size={20} />}
{...props} {...props}
> >
{children} {children}
@@ -27,8 +27,7 @@ const Grid = styled.div`
grid-auto-columns: 1fr; grid-auto-columns: 1fr;
grid-template-areas: 'image info'; grid-template-areas: 'image info';
grid-template-rows: 1fr; grid-template-rows: 1fr;
grid-template-columns: 225px 1fr; grid-template-columns: 200px 1fr;
gap: 0.5rem;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
@@ -152,16 +151,27 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
/> />
</ImageColumn> </ImageColumn>
<InfoColumn> <InfoColumn>
<Stack sx={{ width: '100%' }}> <Stack
spacing="md"
sx={{ width: '100%' }}
>
<TitleWrapper> <TitleWrapper>
<TextTitle fw="bold">{currentItem?.name}</TextTitle> <TextTitle
lh="4rem"
lineClamp={2}
order={1}
sx={{ fontSize: '4rem' }}
weight={900}
>
{currentItem?.name}
</TextTitle>
</TitleWrapper> </TitleWrapper>
<TitleWrapper> <TitleWrapper>
{currentItem?.albumArtists.map((artist) => ( {currentItem?.albumArtists.map((artist) => (
<TextTitle <TextTitle
key={`carousel-artist-${artist.id}`} key={`carousel-artist-${artist.id}`}
fw="600" order={2}
order={3} weight={600}
> >
{artist.name} {artist.name}
</TextTitle> </TextTitle>
@@ -169,10 +179,15 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</TitleWrapper> </TitleWrapper>
<Group> <Group>
{currentItem?.genres?.map((genre) => ( {currentItem?.genres?.map((genre) => (
<Badge key={`carousel-genre-${genre.id}`}>{genre.name}</Badge> <Badge
key={`carousel-genre-${genre.id}`}
size="lg"
>
{genre.name}
</Badge>
))} ))}
<Badge>{currentItem?.releaseYear}</Badge> <Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge>{currentItem?.songCount} tracks</Badge> <Badge size="lg">{currentItem?.songCount} tracks</Badge>
</Group> </Group>
</Stack> </Stack>
</InfoColumn> </InfoColumn>
@@ -187,25 +202,25 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</AnimatePresence> </AnimatePresence>
<Group <Group
spacing="xs" spacing="xs"
sx={{ bottom: 0, position: 'absolute', right: 0, zIndex: 20 }} sx={{ bottom: '1rem', position: 'absolute', right: 0, zIndex: 20 }}
> >
<Button <Button
disabled={itemIndex === 0} disabled={itemIndex === 0}
px="lg"
radius={100} radius={100}
variant="subtle" size="md"
variant="default"
onClick={handlePrevious} onClick={handlePrevious}
> >
<RiArrowLeftSLine size={15} /> <RiArrowLeftSLine size="2rem" />
</Button> </Button>
<Button <Button
disabled={itemIndex === (data?.length || 1) - 1} disabled={itemIndex === (data?.length || 1) - 1}
px="lg"
radius={100} radius={100}
variant="subtle" size="md"
variant="default"
onClick={handleNext} onClick={handleNext}
> >
<RiArrowRightSLine size={15} /> <RiArrowRightSLine size="2rem" />
</Button> </Button>
</Group> </Group>
</Wrapper> </Wrapper>
+35 -23
View File
@@ -6,16 +6,19 @@ import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button'; import { Button } from '/@/renderer/components/button';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import type { CardRow } from '/@/renderer/types'; import type { CardRow } from '/@/renderer/types';
import { LibraryItem, Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import styled from 'styled-components'; import styled from 'styled-components';
import { AlbumCard } from '/@/renderer/components/card'; import { AlbumCard } from '/@/renderer/components/card';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add'; 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 { interface GridCarouselProps {
cardRows: CardRow<any>[]; cardRows: CardRow<any>[];
children: React.ReactElement; children: React.ReactElement;
containerWidth: number; containerWidth: number;
data: any[] | undefined; data: any[] | undefined;
itemType: LibraryItem;
loading?: boolean; loading?: boolean;
pagination?: { pagination?: {
handleNextPage?: () => void; handleNextPage?: () => void;
@@ -77,9 +80,11 @@ const variants: Variants = {
}; };
const Carousel = ({ data, cardRows }: any) => { const Carousel = ({ data, cardRows }: any) => {
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } = const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
useContext(GridCarouselContext); useContext(GridCarouselContext);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
return ( return (
@@ -104,9 +109,9 @@ const Carousel = ({ data, cardRows }: any) => {
key={`card-${uniqueId}-${index}`} key={`card-${uniqueId}-${index}`}
controls={{ controls={{
cardRows, cardRows,
itemType: LibraryItem.ALBUM, itemType: itemType || LibraryItem.ALBUM,
playButtonBehavior: Play.NOW, playButtonBehavior: playButtonBehavior || Play.NOW,
route: { route: cardRows[0]?.route || {
route: AppRoute.LIBRARY_ALBUMS_DETAIL, route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }], slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}, },
@@ -130,6 +135,7 @@ export const GridCarousel = ({
children, children,
containerWidth, containerWidth,
uniqueId, uniqueId,
itemType,
}: GridCarouselProps) => { }: GridCarouselProps) => {
const [direction, setDirection] = useState(0); const [direction, setDirection] = useState(0);
@@ -147,12 +153,13 @@ export const GridCarousel = ({
direction, direction,
gridHeight, gridHeight,
imageSize, imageSize,
itemType,
loading, loading,
pagination, pagination,
setDirection, setDirection,
uniqueId, uniqueId,
}), }),
[cardRows, data, direction, gridHeight, imageSize, loading, pagination, uniqueId], [cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
); );
return ( return (
@@ -176,6 +183,7 @@ interface TitleProps {
const Title = ({ children }: TitleProps) => { const Title = ({ children }: TitleProps) => {
const { pagination, setDirection } = useContext(GridCarouselContext); const { pagination, setDirection } = useContext(GridCarouselContext);
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
setDirection(1); setDirection(1);
@@ -190,23 +198,27 @@ const Title = ({ children }: TitleProps) => {
return ( return (
<Group position="apart"> <Group position="apart">
{children} {children}
<Group> {showPaginationButtons && (
<Button <Group spacing="sm">
compact <Button
disabled={!pagination?.hasPreviousPage} compact
variant="default" disabled={!pagination?.hasPreviousPage}
onClick={handlePreviousPage} size="md"
> variant="default"
<RiArrowLeftSLine size={15} /> onClick={handlePreviousPage}
</Button> >
<Button <RiArrowLeftSLine size={15} />
compact </Button>
variant="default" <Button
onClick={handleNextPage} compact
> size="md"
<RiArrowRightSLine size={15} /> variant="default"
</Button> onClick={handleNextPage}
</Group> >
<RiArrowRightSLine size={15} />
</Button>
</Group>
)}
</Group> </Group>
); );
}; };
@@ -0,0 +1,24 @@
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
return (
<MantineHoverCard
styles={{
dropdown: {
background: 'var(--dropdown-menu-bg)',
border: 'none',
borderRadius: 'var(--dropdown-menu-border-radius)',
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
margin: 0,
padding: 0,
},
}}
{...props}
>
{children}
</MantineHoverCard>
);
};
HoverCard.Target = MantineHoverCard.Target;
HoverCard.Dropdown = MantineHoverCard.Dropdown;
+4 -2
View File
@@ -27,7 +27,9 @@ export * from './text';
export * from './text-title'; export * from './text-title';
export * from './toast'; export * from './toast';
export * from './tooltip'; export * from './tooltip';
export * from './virtual-grid';
export * from './virtual-table';
export * from './motion'; export * from './motion';
export * from './context-menu'; export * from './context-menu';
export * from './query-builder';
export * from './rating';
export * from './hover-card';
export * from './option';
+24
View File
@@ -83,6 +83,10 @@ const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
@@ -130,6 +134,10 @@ const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
@@ -159,6 +167,10 @@ const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
@@ -188,6 +200,10 @@ const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
@@ -217,6 +233,10 @@ const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
@@ -242,6 +262,10 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
`; `;
-2
View File
@@ -21,8 +21,6 @@ export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
export const Modal = ({ children, handlers, ...rest }: ModalProps) => { export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
return ( return (
<MantineModal <MantineModal
overlayBlur={2}
overlayOpacity={0.2}
{...rest} {...rest}
onClose={handlers.close} onClose={handlers.close}
> >
+32
View File
@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { Flex, Group } from '@mantine/core';
export const Option = ({ children }: any) => {
return (
<Group
grow
p="0.5rem"
>
{children}
</Group>
);
};
interface LabelProps {
children: ReactNode;
}
const Label = ({ children }: LabelProps) => {
return <Flex align="flex-start">{children}</Flex>;
};
interface ControlProps {
children: ReactNode;
}
const Control = ({ children }: ControlProps) => {
return <Flex justify="flex-end">{children}</Flex>;
};
Option.Label = Label;
Option.Control = Control;
+17 -6
View File
@@ -1,8 +1,10 @@
import { useRef } from 'react';
import { Flex, FlexProps } from '@mantine/core'; import { Flex, FlexProps } from '@mantine/core';
import { AnimatePresence, motion, Variants } from 'framer-motion'; import { AnimatePresence, motion, Variants } from 'framer-motion';
import { useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks'; import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
const Container = styled(motion(Flex))<{ const Container = styled(motion(Flex))<{
height?: string; height?: string;
@@ -11,18 +13,23 @@ const Container = styled(motion(Flex))<{
position: ${(props) => props.position || 'relative'}; position: ${(props) => props.position || 'relative'};
z-index: 2000; z-index: 2000;
width: 100%; width: 100%;
height: ${(props) => props.height || '60px'}; height: ${(props) => props.height || '65px'};
background: var(--titlebar-bg);
`; `;
const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>` const Header = styled(motion.div)<{
$isDraggable?: boolean;
$isHidden?: boolean;
$padRight?: boolean;
}>`
position: relative; position: relative;
z-index: 15; z-index: 15;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin-right: ${(props) => props.$padRight && '170px'}; margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')}; user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')}; pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
-webkit-app-region: drag; -webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
button { button {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
@@ -51,7 +58,7 @@ const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
height: 100%; height: 100%;
background: ${(props) => background: ${(props) =>
props.theme === 'light' props.theme === 'light'
? 'linear-gradient(rgba(0, 0, 0, 20%), rgba(0, 0, 0, 20%))' ? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'}; : 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
`; `;
@@ -65,6 +72,8 @@ export interface PageHeaderProps
} }
const TitleWrapper = styled(motion.div)` const TitleWrapper = styled(motion.div)`
position: absolute;
display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
`; `;
@@ -85,6 +94,7 @@ export const PageHeader = ({
}: PageHeaderProps) => { }: PageHeaderProps) => {
const ref = useRef(null); const ref = useRef(null);
const padRight = useShouldPadTitlebar(); const padRight = useShouldPadTitlebar();
const { windowBarStyle } = useWindowSettings();
const theme = useTheme(); const theme = useTheme();
return ( return (
@@ -95,6 +105,7 @@ export const PageHeader = ({
{...props} {...props}
> >
<Header <Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden} $isHidden={isHidden}
$padRight={padRight} $padRight={padRight}
> >
@@ -46,7 +46,6 @@ export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
<StyledPagination <StyledPagination
$hideDividers={$hideDividers} $hideDividers={$hideDividers}
radius="xl" radius="xl"
size="md"
{...props} {...props}
/> />
); );
+1 -1
View File
@@ -27,7 +27,7 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))', filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
}, },
}} }}
transition="scale-y" transitionProps={{ transition: 'fade' }}
{...props} {...props}
> >
{children} {children}
@@ -0,0 +1,218 @@
import { Group, Stack } from '@mantine/core';
import { Select } from '/@/renderer/components/select';
import { AnimatePresence, motion } from 'framer-motion';
import { RiAddFill, RiAddLine, RiDeleteBinFill, RiMore2Line, RiRestartLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
const FILTER_GROUP_OPTIONS_DATA = [
{
label: 'Match all',
value: 'all',
},
{
label: 'Match any',
value: 'any',
},
];
type AddArgs = {
groupIndex: number[];
level: number;
};
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
};
interface QueryBuilderProps {
data: Record<string, any>;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
onAddRule: (args: AddArgs) => void;
onAddRuleGroup: (args: AddArgs) => void;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeType: (args: any) => void;
onChangeValue: (args: any) => void;
onClearFilters: () => void;
onDeleteRule: (args: DeleteArgs) => void;
onDeleteRuleGroup: (args: DeleteArgs) => void;
onResetFilters: () => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
uniqueId: string;
}
export const QueryBuilder = ({
data,
level,
onAddRule,
onDeleteRuleGroup,
onDeleteRule,
onAddRuleGroup,
onChangeType,
onChangeField,
operators,
onChangeOperator,
onChangeValue,
onClearFilters,
onResetFilters,
groupIndex,
uniqueId,
filters,
}: QueryBuilderProps) => {
const handleAddRule = () => {
onAddRule({ groupIndex, level });
};
const handleAddRuleGroup = () => {
onAddRuleGroup({ groupIndex, level });
};
const handleDeleteRuleGroup = () => {
onDeleteRuleGroup({ groupIndex, level, uniqueId });
};
const handleChangeType = (value: string | null) => {
onChangeType({ groupIndex, level, value });
};
return (
<Stack
ml={`${level * 10}px`}
spacing="sm"
>
<Group spacing="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
maxWidth={175}
size="sm"
value={data.type}
width="20%"
onChange={handleChangeType}
/>
<Button
px={5}
size="sm"
tooltip={{ label: 'Add rule' }}
variant="default"
onClick={handleAddRule}
>
<RiAddLine size={20} />
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
p={0}
size="sm"
variant="subtle"
>
<RiMore2Line size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiAddFill />}
onClick={handleAddRuleGroup}
>
Add rule group
</DropdownMenu.Item>
{level > 0 && (
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={handleDeleteRuleGroup}
>
Remove rule group
</DropdownMenu.Item>
)}
{level === 0 && (
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
icon={<RiRestartLine color="var(--danger-color)" />}
onClick={onResetFilters}
>
Reset to default
</DropdownMenu.Item>
<DropdownMenu.Item
$danger
icon={<RiDeleteBinFill color="var(--danger-color)" />}
onClick={onClearFilters}
>
Clear filters
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<AnimatePresence initial={false}>
{data?.rules?.map((rule: QueryBuilderRule) => (
<motion.div
key={rule.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilderOption
data={rule}
filters={filters}
groupIndex={groupIndex || []}
level={level}
noRemove={data?.rules?.length === 1}
operators={operators}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeValue={onChangeValue}
onDeleteRule={onDeleteRule}
/>
</motion.div>
))}
</AnimatePresence>
{data?.group && (
<AnimatePresence initial={false}>
{data.group?.map((group: QueryBuilderGroup, index: number) => (
<motion.div
key={group.uniqueId}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -25 }}
initial={{ opacity: 0, x: -25 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<QueryBuilder
data={group}
filters={filters}
groupIndex={[...(groupIndex || []), index]}
level={level + 1}
operators={operators}
uniqueId={group.uniqueId}
onAddRule={onAddRule}
onAddRuleGroup={onAddRuleGroup}
onChangeField={onChangeField}
onChangeOperator={onChangeOperator}
onChangeType={onChangeType}
onChangeValue={onChangeValue}
onClearFilters={onClearFilters}
onDeleteRule={onDeleteRule}
onDeleteRuleGroup={onDeleteRuleGroup}
onResetFilters={onResetFilters}
/>
</motion.div>
))}
</AnimatePresence>
)}
</Stack>
);
};
@@ -0,0 +1,239 @@
import { Group } from '@mantine/core';
import { useState } from 'react';
import { RiSubtractLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { NumberInput, TextInput } from '/@/renderer/components/input';
import { Select } from '/@/renderer/components/select';
import { QueryBuilderRule } from '/@/renderer/types';
type DeleteArgs = {
groupIndex: number[];
level: number;
uniqueId: string;
};
interface QueryOptionProps {
data: QueryBuilderRule;
filters: { label: string; type: string; value: string }[];
groupIndex: number[];
level: number;
noRemove: boolean;
onChangeField: (args: any) => void;
onChangeOperator: (args: any) => void;
onChangeValue: (args: any) => void;
onDeleteRule: (args: DeleteArgs) => void;
operators: {
boolean: { label: string; value: string }[];
date: { label: string; value: string }[];
number: { label: string; value: string }[];
string: { label: string; value: string }[];
};
}
const QueryValueInput = ({ onChange, type, ...props }: any) => {
const [numberRange, setNumberRange] = useState([0, 0]);
switch (type) {
case 'string':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'number':
return (
<NumberInput
size="sm"
onChange={onChange}
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue)}
/>
);
case 'date':
return (
<TextInput
size="sm"
onChange={onChange}
{...props}
/>
);
case 'dateRange':
return (
<>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [e || 0, numberRange[1]];
setNumberRange(newRange);
onChange(newRange);
}}
/>
<NumberInput
{...props}
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
maxWidth={81}
width="10%"
onChange={(e) => {
const newRange = [numberRange[0], e || 0];
setNumberRange(newRange);
onChange(newRange);
}}
/>
</>
);
case 'boolean':
return (
<Select
data={[
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]}
onChange={onChange}
{...props}
/>
);
default:
return <></>;
}
};
export const QueryBuilderOption = ({
data,
filters,
level,
onDeleteRule,
operators,
groupIndex,
noRemove,
onChangeField,
onChangeOperator,
onChangeValue,
}: QueryOptionProps) => {
const { field, operator, uniqueId, value } = data;
const handleDeleteRule = () => {
onDeleteRule({ groupIndex, level, uniqueId });
};
const handleChangeField = (e: any) => {
onChangeField({ groupIndex, level, uniqueId, value: e });
};
const handleChangeOperator = (e: any) => {
onChangeOperator({ groupIndex, level, uniqueId, value: e });
};
const handleChangeValue = (e: any) => {
const isDirectValue =
typeof e === 'string' ||
typeof e === 'number' ||
typeof e === 'undefined' ||
typeof e === null;
if (isDirectValue) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
// const isDate = e instanceof Date;
// if (isDate) {
// return onChangeValue({
// groupIndex,
// level,
// uniqueId,
// value: dayjs(e).format('YYYY-MM-DD'),
// });
// }
const isArray = Array.isArray(e);
if (isArray) {
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e,
});
}
return onChangeValue({
groupIndex,
level,
uniqueId,
value: e.currentTarget.value,
});
};
const fieldType = filters.find((f) => f.value === field)?.type;
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
const ml = (level + 1) * 10;
return (
<Group
ml={ml}
spacing="sm"
>
<Select
searchable
data={filters}
maxWidth={170}
size="sm"
value={field}
width="25%"
onChange={handleChangeField}
/>
<Select
searchable
data={operatorsByFieldType || []}
disabled={!field}
maxWidth={170}
size="sm"
value={operator}
width="25%"
onChange={handleChangeOperator}
/>
{field ? (
<QueryValueInput
defaultValue={value}
maxWidth={170}
size="sm"
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
width="25%"
onChange={handleChangeValue}
/>
) : (
<TextInput
disabled
defaultValue={value}
maxWidth={170}
size="sm"
width="25%"
onChange={handleChangeValue}
/>
)}
<Button
disabled={noRemove}
px={5}
size="sm"
tooltip={{ label: 'Remove rule' }}
variant="default"
onClick={handleDeleteRule}
>
<RiSubtractLine size={20} />
</Button>
</Group>
);
};
+33
View File
@@ -0,0 +1,33 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { MouseEvent } from 'react';
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
import styled from 'styled-components';
import { Tooltip } from '/@/renderer/components/tooltip';
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
}
const StyledRating = styled(MantineRating)`
& .mantine-Rating-symbolBody {
svg {
stroke: var(--main-fg-secondary);
}
}
`;
export const Rating = ({ onClick, ...props }: RatingProps) => {
// const debouncedOnClick = debounce(onClick, 100);
return (
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<StyledRating
{...props}
onDoubleClick={(e) => onClick(e, props.value)}
/>
</Tooltip>
);
};
+36 -12
View File
@@ -5,6 +5,8 @@ import { useMergedRef, useTimeout } from '@mantine/hooks';
import { motion, useScroll } from 'framer-motion'; import { motion, useScroll } from 'framer-motion';
import styled from 'styled-components'; import styled from 'styled-components';
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header'; import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
interface ScrollAreaProps extends MantineScrollAreaProps { interface ScrollAreaProps extends MantineScrollAreaProps {
children: React.ReactNode; children: React.ReactNode;
@@ -26,16 +28,18 @@ const StyledScrollArea = styled(MantineScrollArea)`
} }
`; `;
const StyledNativeScrollArea = styled.div` const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
height: 100%; height: 100%;
overflow-y: overlay; overflow-y: overlay !important;
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
margin-top: 35px; margin-top: ${(props) =>
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
margin-top: 35px; margin-top: ${(props) =>
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
} }
`; `;
@@ -54,17 +58,33 @@ export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, r
interface NativeScrollAreaProps { interface NativeScrollAreaProps {
children: React.ReactNode; children: React.ReactNode;
debugScrollPosition?: boolean; debugScrollPosition?: boolean;
noHeader?: boolean;
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any }; pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
scrollBarOffset?: string;
scrollHideDelay?: number;
style?: React.CSSProperties;
} }
export const NativeScrollArea = forwardRef( export const NativeScrollArea = forwardRef(
( (
{ children, pageHeaderProps, debugScrollPosition, ...props }: NativeScrollAreaProps, {
children,
pageHeaderProps,
debugScrollPosition,
scrollBarOffset,
scrollHideDelay,
noHeader,
...props
}: NativeScrollAreaProps,
ref: Ref<HTMLDivElement>, ref: Ref<HTMLDivElement>,
) => { ) => {
const { windowBarStyle } = useWindowSettings();
const [hideScrollbar, setHideScrollbar] = useState(false); const [hideScrollbar, setHideScrollbar] = useState(false);
const [hideHeader, setHideHeader] = useState(true); const [hideHeader, setHideHeader] = useState(true);
const { start, clear } = useTimeout(() => setHideScrollbar(true), 1000); const { start, clear } = useTimeout(
() => setHideScrollbar(true),
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
);
const containerRef = useRef(null); const containerRef = useRef(null);
const mergedRef = useMergedRef(ref, containerRef); const mergedRef = useMergedRef(ref, containerRef);
@@ -103,15 +123,19 @@ export const NativeScrollArea = forwardRef(
return ( return (
<> <>
<PageHeader {!noHeader && (
isHidden={hideHeader} <PageHeader
position="absolute" isHidden={hideHeader}
style={{ opacity: scrollYProgress as any }} position="absolute"
{...pageHeaderProps} style={{ opacity: scrollYProgress as any }}
/> {...pageHeaderProps}
/>
)}
<StyledNativeScrollArea <StyledNativeScrollArea
ref={mergedRef} ref={mergedRef}
className={hideScrollbar ? 'hide-scrollbar' : undefined} className={hideScrollbar ? 'hide-scrollbar' : undefined}
scrollBarOffset={scrollBarOffset}
windowBarStyle={windowBarStyle}
onMouseEnter={() => { onMouseEnter={() => {
setHideScrollbar(false); setHideScrollbar(false);
clear(); clear();
@@ -43,9 +43,10 @@ export const SearchInput = ({
<TextInput <TextInput
ref={mergedRef} ref={mergedRef}
{...props} {...props}
icon={showIcon && <RiSearchLine size={15} />} icon={showIcon && <RiSearchLine />}
size="md"
styles={{ styles={{
icon: { svg: { fill: 'var(--btn-default-fg)' } }, icon: { svg: { fill: 'var(--titlebar-fg)' } },
input: { input: {
backgroundColor: isOpened ? 'inherit' : 'transparent !important', backgroundColor: isOpened ? 'inherit' : 'transparent !important',
border: 'none !important', border: 'none !important',
@@ -53,7 +54,7 @@ export const SearchInput = ({
padding: isOpened ? '10px' : 0, padding: isOpened ? '10px' : 0,
}, },
}} }}
width={isOpened ? openedWidth || 150 : initialWidth || 50} width={isOpened ? openedWidth || 150 : initialWidth || 35}
onChange={onChange} onChange={onChange}
onKeyDown={handleEscape} onKeyDown={handleEscape}
/> />
@@ -17,6 +17,10 @@ const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedC
opacity: 0.6; opacity: 0.6;
} }
& [data-disabled='true'] {
opacity: 0.6;
}
& .mantine-SegmentedControl-active { & .mantine-SegmentedControl-active {
color: var(--input-active-fg); color: var(--input-active-fg);
background-color: var(--input-active-bg); background-color: var(--input-active-bg);
+5 -7
View File
@@ -20,7 +20,7 @@ const StyledSelect = styled(MantineSelect)`
background: var(--input-bg); background: var(--input-bg);
} }
& .mantine-Select-disabled { & [data-disabled='true'] {
background: var(--input-bg); background: var(--input-bg);
opacity: 0.6; opacity: 0.6;
} }
@@ -64,8 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
}, },
}} }}
sx={{ maxWidth, width }} sx={{ maxWidth, width }}
transition="pop" transitionProps={{ duration: 100, transition: 'fade' }}
transitionDuration={100}
{...props} {...props}
/> />
); );
@@ -76,8 +75,8 @@ const StyledMultiSelect = styled(MantineMultiSelect)`
background: var(--input-select-bg); background: var(--input-select-bg);
} }
& .mantine-MultiSelect-disabled { & [data-disabled='true'] {
background: var(--input-select-bg); background: var(--input-bg);
opacity: 0.6; opacity: 0.6;
} }
@@ -126,8 +125,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
}, },
}} }}
sx={{ maxWidth, width }} sx={{ maxWidth, width }}
transition="pop" transitionProps={{ duration: 100, transition: 'fade' }}
transitionDuration={100}
{...props} {...props}
/> />
); );
+4
View File
@@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
background-color: var(--slider-track-bg); background-color: var(--slider-track-bg);
} }
& .mantine-Slider-bar {
background-color: var(--primary-color);
}
& .mantine-Slider-thumb { & .mantine-Slider-thumb {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
+19 -8
View File
@@ -1,5 +1,5 @@
import type { TabsProps as MantineTabsProps } from '@mantine/core'; import { Suspense } from 'react';
import { Tabs as MantineTabs } from '@mantine/core'; import { TabsPanelProps, TabsProps as MantineTabsProps, Tabs as MantineTabs } from '@mantine/core';
import styled from 'styled-components'; import styled from 'styled-components';
type TabsProps = MantineTabsProps; type TabsProps = MantineTabsProps;
@@ -12,16 +12,20 @@ const StyledTabs = styled(MantineTabs)`
} }
&.mantine-Tabs-tab { &.mantine-Tabs-tab {
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 1rem;
background-color: var(--main-bg); background-color: var(--main-bg);
} }
& .mantine-Tabs-panel { & .mantine-Tabs-panel {
padding: 0 1rem; padding: 1.5rem 0.5rem;
} }
button { button {
padding: 1rem; padding: 1rem;
color: var(--btn-subtle-fg); color: var(--btn-subtle-fg);
border-radius: 0;
&:hover { &:hover {
color: var(--btn-subtle-fg-hover); color: var(--btn-subtle-fg-hover);
@@ -32,13 +36,12 @@ const StyledTabs = styled(MantineTabs)`
} }
button[data-active] { button[data-active] {
color: var(--btn-primary-fg); color: var(--btn-subtle-fg);
background: var(--primary-color); background: none;
border-color: var(--primary-color); border-color: var(--primary-color);
&:hover { &:hover {
background: var(--btn-primary-bg-hover); background: none;
border-color: var(--primary-color);
} }
} }
`; `;
@@ -47,6 +50,14 @@ export const Tabs = ({ children, ...props }: TabsProps) => {
return <StyledTabs {...props}>{children}</StyledTabs>; return <StyledTabs {...props}>{children}</StyledTabs>;
}; };
const Panel = ({ children, ...props }: TabsPanelProps) => {
return (
<StyledTabs.Panel {...props}>
<Suspense fallback={<></>}>{children}</Suspense>
</StyledTabs.Panel>
);
};
Tabs.List = StyledTabs.List; Tabs.List = StyledTabs.List;
Tabs.Panel = StyledTabs.Panel; Tabs.Panel = Panel;
Tabs.Tab = StyledTabs.Tab; Tabs.Tab = StyledTabs.Tab;
+1 -1
View File
@@ -10,7 +10,7 @@ interface TextTitleProps extends MantineTextTitleDivProps {
$link?: boolean; $link?: boolean;
$noSelect?: boolean; $noSelect?: boolean;
$secondary?: boolean; $secondary?: boolean;
children: ReactNode; children?: ReactNode;
overflow?: 'hidden' | 'visible'; overflow?: 'hidden' | 'visible';
to?: string; to?: string;
weight?: number; weight?: number;
+2 -2
View File
@@ -11,7 +11,7 @@ interface TextProps extends MantineTextDivProps {
$link?: boolean; $link?: boolean;
$noSelect?: boolean; $noSelect?: boolean;
$secondary?: boolean; $secondary?: boolean;
children: ReactNode; children?: ReactNode;
font?: Font; font?: Font;
overflow?: 'hidden' | 'visible'; overflow?: 'hidden' | 'visible';
to?: string; to?: string;
@@ -33,7 +33,7 @@ const StyledText = styled(MantineText)<TextProps>`
} }
`; `;
const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => { export const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
return ( return (
<StyledText <StyledText
$noSelect={$noSelect} $noSelect={$noSelect}
+10 -5
View File
@@ -30,16 +30,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
? 'Error' ? 'Error'
: 'Info'; : 'Info';
const defaultDuration = type === 'error' ? 3500 : 2000; const defaultDuration = type === 'error' ? 4000 : 2000;
return showNotification({ return showNotification({
autoClose: defaultDuration, autoClose: defaultDuration,
disallowClose: true,
styles: () => ({ styles: () => ({
closeButton: {}, closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: { description: {
color: 'var(--toast-description-fg)', color: 'var(--toast-description-fg)',
fontSize: '.9em', fontSize: '1rem',
}, },
loader: { loader: {
margin: '1rem', margin: '1rem',
@@ -47,10 +50,12 @@ const showToast = ({ type, ...props }: NotificationProps) => {
root: { root: {
'&::before': { backgroundColor: color }, '&::before': { backgroundColor: color },
background: 'var(--toast-bg)', background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
}, },
title: { title: {
color: 'var(--toast-title-fg)', color: 'var(--toast-title-fg)',
fontSize: '1em', fontSize: '1.3rem',
}, },
}), }),
title: defaultTitle, title: defaultTitle,
+4 -2
View File
@@ -27,6 +27,10 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
maxWidth: '250px', maxWidth: '250px',
}, },
}} }}
transitionProps={{
duration: 250,
transition: 'fade',
}}
{...rest} {...rest}
> >
{children} {children}
@@ -37,8 +41,6 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
Tooltip.defaultProps = { Tooltip.defaultProps = {
openDelay: 0, openDelay: 0,
position: 'top', position: 'top',
transition: 'fade',
transitionDuration: 250,
withArrow: true, withArrow: true,
withinPortal: true, withinPortal: true,
}; };
@@ -1,38 +1,58 @@
import { Center } from '@mantine/core'; import { generatePath, useNavigate } from 'react-router-dom';
import { RiAlbumFill } from 'react-icons/ri'; import { ListChildComponentProps } from 'react-window';
import { generatePath, useNavigate } from 'react-router';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import styled from 'styled-components'; import styled from 'styled-components';
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types'; import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card';
import { Skeleton } from '/@/renderer/components/skeleton'; import { Skeleton } from '/@/renderer/components/skeleton';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls'; import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types'; import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
import { CardRows } from '/@/renderer/components/card';
const CardWrapper = styled.div<{ interface BaseGridCardProps {
itemGap: number; columnIndex: number;
itemHeight: number; controls: {
itemWidth: number; cardRows: CardRow<Album | AlbumArtist | Artist>[];
link?: boolean; handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
}>` handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`}; itemType: LibraryItem;
width: ${({ itemWidth }) => `${itemWidth}px`}; playButtonBehavior: Play;
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`}; route: CardRoute;
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`}; };
padding: 12px 12px 0; data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
}
const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex;
flex-direction: column;
width: 100%;
height: calc(100% - 2rem);
margin: 0.5rem;
overflow: hidden;
background: var(--card-default-bg); background: var(--card-default-bg);
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'}; cursor: pointer;
transition: border 0.2s ease-in-out, background 0.2s ease-in-out; opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
user-select: none; pointer-events: auto;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&:hover { &:hover {
background: var(--card-default-bg-hover); background: var(--card-default-bg-hover);
} }
`;
&:hover div { const InnerCardContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 1rem;
overflow: hidden;
.card-controls {
opacity: 0;
}
&:hover .card-controls {
opacity: 1; opacity: 1;
} }
@@ -41,26 +61,16 @@ const CardWrapper = styled.div<{
opacity: 0.5; opacity: 0.5;
} }
} }
&:focus-visible {
outline: 1px solid #fff;
}
`; `;
const StyledCard = styled.div` const ImageContainer = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--card-default-radius);
`;
const ImageSection = styled.div<{ size?: number }>`
position: relative; position: relative;
width: ${({ size }) => size && `${size - 24}px`}; display: flex;
height: ${({ size }) => size && `${size - 24}px`}; align-items: center;
height: 100%;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--placeholder-bg);
border-radius: var(--card-default-radius); border-radius: var(--card-default-radius);
&::before { &::before {
@@ -78,151 +88,80 @@ const ImageSection = styled.div<{ size?: number }>`
} }
`; `;
const Image = styled(SimpleImg)` const Image = styled.img`
border-radius: var(--card-default-radius);
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
`;
const ControlsContainer = styled.div`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%; width: 100%;
opacity: 0; max-width: 100%;
transition: all 0.2s ease-in-out; height: auto;
object-fit: contain;
border: 0;
`; `;
const DetailSection = styled.div` const DetailContainer = styled.div`
display: flex; margin-top: 0.5rem;
flex-direction: column;
`; `;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
sizes: {
itemGap: number;
itemHeight: number;
itemWidth: number;
};
}
export const DefaultCard = ({ export const DefaultCard = ({
listChildProps, listChildProps,
data, data,
columnIndex, columnIndex,
controls, controls,
sizes, isHidden,
}: BaseGridCardProps) => { }: BaseGridCardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { index } = listChildProps;
const { itemGap, itemHeight, itemWidth } = sizes;
const { itemType, cardRows, route, handlePlayQueueAdd } = controls;
const cardSize = itemWidth - 24;
if (data) { if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
return ( return (
<CardWrapper <DefaultCardContainer
key={`card-${columnIndex}-${index}`} key={`card-${columnIndex}-${listChildProps.index}`}
link onClick={() => navigate(path)}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
onClick={() =>
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
)
}
> >
<StyledCard> <InnerCardContainer>
<ImageSection size={itemWidth}> <ImageContainer>
{data?.imageUrl ? ( <Image
<Image placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
animationDuration={0.3} src={data?.imageUrl}
height={cardSize} />
imgStyle={{ objectFit: 'cover' }} <GridCardControls
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'} handleFavorite={controls.handleFavorite}
src={data?.imageUrl} handlePlayQueueAdd={controls.handlePlayQueueAdd}
width={cardSize} itemData={data}
/> itemType={controls.itemType}
) : ( />
<Center </ImageContainer>
sx={{ <DetailContainer>
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: '100%',
width: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<GridCardControls
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
<CardRows <CardRows
data={data} data={data}
rows={cardRows} rows={controls.cardRows}
/> />
</DetailSection> </DetailContainer>
</StyledCard> </InnerCardContainer>
</CardWrapper> </DefaultCardContainer>
); );
} }
return ( return (
<CardWrapper <DefaultCardContainer
key={`card-${columnIndex}-${index}`} key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={itemGap} $isHidden={isHidden}
itemHeight={itemHeight}
itemWidth={itemWidth + 12}
> >
<StyledCard> <InnerCardContainer>
<Skeleton <ImageContainer>
visible <Skeleton
radius="sm" visible
> radius="sm"
<ImageSection size={itemWidth} /> />
</Skeleton> </ImageContainer>
<DetailSection> </InnerCardContainer>
{cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => ( </DefaultCardContainer>
<Skeleton
key={`row-${row.property}-${columnIndex}`}
height={20}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
/>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
); );
}; };
@@ -1,18 +1,23 @@
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import React from 'react'; import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core'; import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core'; import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button'; import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu'; import type { PlayQueueAddOptions } from '/@/renderer/types';
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
import { Play } from '/@/renderer/types'; import { Play } from '/@/renderer/types';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { LibraryItem } from '/@/renderer/api/types';
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
import {
ALBUM_CONTEXT_MENU_ITEMS,
ARTIST_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>; type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled.button<PlayButtonType>` const PlayButton = styled.button<PlayButtonType>`
position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -23,7 +28,7 @@ const PlayButton = styled.button<PlayButtonType>`
border-radius: 50%; border-radius: 50%;
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear; transition: scale 0.1s ease-in-out;
&:hover { &:hover {
opacity: 1; opacity: 1;
@@ -58,6 +63,8 @@ const SecondaryButton = styled(_Button)`
`; `;
const GridCardControlsContainer = styled.div` const GridCardControlsContainer = styled.div`
position: absolute;
z-index: 100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -71,24 +78,13 @@ const ControlsRow = styled.div`
height: calc(100% / 3); height: calc(100% / 3);
`; `;
// const TopControls = styled(ControlsRow)`
// display: flex;
// align-items: flex-start;
// justify-content: space-between;
// padding: 0.5rem;
// `;
// const CenterControls = styled(ControlsRow)`
// display: flex;
// align-items: center;
// justify-content: center;
// padding: 0.5rem;
// `;
const BottomControls = styled(ControlsRow)` const BottomControls = styled(ControlsRow)`
position: absolute;
bottom: 0;
display: flex; display: flex;
gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: flex-end;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
`; `;
@@ -98,31 +94,18 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
} }
`; `;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Add to queue (last)',
play: Play.LAST,
},
{
label: 'Add to queue (next)',
play: Play.NEXT,
},
];
export const GridCardControls = ({ export const GridCardControls = ({
itemData, itemData,
itemType, itemType,
handlePlayQueueAdd, handlePlayQueueAdd,
handleFavorite,
}: { }: {
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any; itemData: any;
itemType: LibraryItem; itemType: LibraryItem;
}) => { }) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior); const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => { const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault(); e.preventDefault();
@@ -137,66 +120,58 @@ export const GridCardControls = ({
}); });
}; };
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
handleFavorite?.({
id: [itemData.id],
isFavorite: itemData.userFavorite,
itemType,
});
};
const handleContextMenu = useHandleGeneralContextMenu(
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
return ( return (
<GridCardControlsContainer> <GridCardControlsContainer className="card-controls">
{/* <TopControls /> */} <PlayButton onClick={handlePlay}>
{/* <CenterControls /> */} <RiPlayFill size={25} />
</PlayButton>
<BottomControls> <BottomControls>
<PlayButton onClick={handlePlay}> <SecondaryButton
<RiPlayFill size={25} /> p={5}
</PlayButton> variant="subtle"
<Group spacing="xs"> onClick={handleFavorites}
<SecondaryButton >
disabled <FavoriteWrapper isFavorite={itemData?.isFavorite}>
p={5} {itemData?.userFavorite ? (
sx={{ svg: { fill: 'white !important' } }} <RiHeartFill size={20} />
variant="subtle" ) : (
> <RiHeartLine
<FavoriteWrapper isFavorite={itemData?.isFavorite}> color="white"
{itemData?.isFavorite ? ( size={20}
<RiHeartFill size={20} /> />
) : ( )}
<RiHeartLine </FavoriteWrapper>
color="white" </SecondaryButton>
size={20} <SecondaryButton
/> p={5}
)} variant="subtle"
</FavoriteWrapper> onClick={(e) => {
</SecondaryButton> e.preventDefault();
<DropdownMenu e.stopPropagation();
withinPortal handleContextMenu(e, [itemData]);
position="bottom-start" }}
> >
<DropdownMenu.Target> <RiMoreFill
<SecondaryButton color="white"
p={5} size={20}
sx={{ svg: { fill: 'white !important' } }} />
variant="subtle" </SecondaryButton>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</BottomControls> </BottomControls>
</GridCardControlsContainer> </GridCardControlsContainer>
); );
@@ -3,21 +3,18 @@ import type { ListChildComponentProps } from 'react-window';
import { areEqual } from 'react-window'; import { areEqual } from 'react-window';
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card'; import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card'; import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
import type { GridCardData } from '/@/renderer/types'; import { GridCardData, ListDisplayType } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types';
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => { export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
const { const {
itemHeight,
itemWidth,
columnCount, columnCount,
itemGap,
itemCount, itemCount,
cardRows, cardRows,
itemData, itemData,
itemType, itemType,
playButtonBehavior, playButtonBehavior,
handlePlayQueueAdd, handlePlayQueueAdd,
handleFavorite,
route, route,
display, display,
} = data as GridCardData; } = data as GridCardData;
@@ -26,39 +23,41 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
const startIndex = index * columnCount; const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1); const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const columnCountInRow = stopIndex - startIndex + 1;
let columnCountToAdd = 0;
if (columnCountInRow !== columnCount) {
columnCountToAdd = columnCount - columnCountInRow;
}
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard; const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
for (let i = startIndex; i <= stopIndex; i += 1) { for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
cards.push( cards.push(
<View <View
key={`card-${i}-${index}`} key={`card-${i}-${index}`}
columnIndex={i} columnIndex={i}
controls={{ controls={{
cardRows, cardRows,
handleFavorite,
handlePlayQueueAdd, handlePlayQueueAdd,
itemType, itemType,
playButtonBehavior, playButtonBehavior,
route, route,
}} }}
data={itemData[i]} data={itemData[i]}
isHidden={i > stopIndex}
listChildProps={{ index }} listChildProps={{ index }}
sizes={{ itemGap, itemHeight, itemWidth }}
/>, />,
); );
} }
return ( return (
<> <div
<div style={{
style={{ ...style,
...style, display: 'flex',
alignItems: 'center', }}
display: 'flex', >
justifyContent: 'start', {cards}
}} </div>
>
{cards}
</div>
</>
); );
}, areEqual); }, areEqual);
@@ -1,61 +1,55 @@
import { Center } from '@mantine/core'; import { Stack } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri'; import { generatePath, useNavigate } from 'react-router-dom';
import { generatePath } from 'react-router'; import { ListChildComponentProps } from 'react-window';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import styled from 'styled-components'; import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton'; import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { Album, Artist, AlbumArtist } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card'; 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';
const CardWrapper = styled.div<{ interface BaseGridCardProps {
itemGap: number; columnIndex: number;
itemHeight: number; controls: {
itemWidth: number; cardRows: CardRow<Album | AlbumArtist | Artist>[];
}>` handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`}; handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
width: ${({ itemWidth }) => `${itemWidth}px`}; itemType: LibraryItem;
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`}; playButtonBehavior: Play;
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`}; route: CardRoute;
user-select: none; };
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682 data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
}
&:hover div { const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0; margin: 1rem;
background: var(--card-poster-bg); overflow: hidden;
border-radius: var(--card-poster-radius); opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
&:hover { .card-controls {
background: var(--card-poster-bg-hover); opacity: 0;
} }
`; `;
const ImageSection = styled.div` const LinkContainer = styled.div`
cursor: pointer;
`;
const ImageContainer = styled.div`
position: relative; position: relative;
width: 100%; display: flex;
align-items: center;
height: 100%;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--card-default-bg);
border-radius: var(--card-poster-radius); border-radius: var(--card-poster-radius);
&::before { &::before {
@@ -71,153 +65,99 @@ const ImageSection = styled.div`
content: ''; content: '';
user-select: none; user-select: none;
} }
`;
interface ImageProps { &:hover {
height: number; &::before {
isLoading?: boolean; opacity: 0.5;
} }
}
const Image = styled(SimpleImg)<ImageProps>` &:hover .card-controls {
border: 0; opacity: 1;
border-radius: var(--card-poster-radius);
img {
object-fit: cover;
} }
`; `;
const ControlsContainer = styled.div` const Image = styled.img`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%; width: 100%;
opacity: 0; max-width: 100%;
transition: all 0.2s ease-in-out; height: auto;
object-fit: cover;
border: 0;
`; `;
const DetailSection = styled.div` const DetailContainer = styled.div`
display: flex; margin-top: 0.5rem;
flex-direction: column;
`; `;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
sizes: {
itemGap: number;
itemHeight: number;
itemWidth: number;
};
}
export const PosterCard = ({ export const PosterCard = ({
listChildProps, listChildProps,
data, data,
columnIndex, columnIndex,
controls, controls,
sizes, isHidden,
}: BaseGridCardProps) => { }: BaseGridCardProps) => {
const navigate = useNavigate();
if (data) { if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
return ( return (
<CardWrapper <PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
key={`card-${columnIndex}-${listChildProps.index}`} <LinkContainer onClick={() => navigate(path)}>
itemGap={sizes.itemGap} <ImageContainer>
itemHeight={sizes.itemHeight} <Image
itemWidth={sizes.itemWidth} placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
> src={data?.imageUrl}
<StyledCard>
<Link
tabIndex={0}
to={generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
)}
>
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={sizes.itemWidth}
importance="auto"
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
width={sizes.itemWidth}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-poster-radius)',
height: '100%',
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<GridCardControls
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ControlsContainer>
</ImageSection>
</Link>
<DetailSection>
<CardRows
data={data}
rows={controls.cardRows}
/> />
</DetailSection> <GridCardControls
</StyledCard> handleFavorite={controls.handleFavorite}
</CardWrapper> handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
</LinkContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</PosterCardContainer>
); );
} }
return ( return (
<CardWrapper <PosterCardContainer
key={`card-${columnIndex}-${listChildProps.index}`} key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={sizes.itemGap} $isHidden={isHidden}
itemHeight={sizes.itemHeight}
itemWidth={sizes.itemWidth}
> >
<StyledCard> <Skeleton
<Skeleton visible
visible radius="sm"
radius="sm" >
> <ImageContainer />
<ImageSection style={{ height: `${sizes.itemWidth}px` }} /> </Skeleton>
</Skeleton> <DetailContainer>
<DetailSection> <Stack spacing="sm">
{controls.cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => ( {controls.cardRows.map((row) => (
<Skeleton <Skeleton
key={`row-${row.property}-${columnIndex}`} key={row.arrayProperty}
height={20} visible
my={2} height={14}
radius="md" radius="sm"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
/> />
))} ))}
</DetailSection> </Stack>
</StyledCard> </DetailContainer>
</CardWrapper> </PosterCardContainer>
); );
}; };
@@ -5,14 +5,8 @@ import type { FixedSizeListProps } from 'react-window';
import { FixedSizeList } from 'react-window'; import { FixedSizeList } from 'react-window';
import styled from 'styled-components'; import styled from 'styled-components';
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card'; import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
import type { import type { CardRow, ListDisplayType, CardRoute, PlayQueueAddOptions } from '/@/renderer/types';
CardRow, import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
LibraryItem,
ListDisplayType,
CardRoute,
PlayQueueAddOptions,
} from '/@/renderer/types';
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
const createItemData = memoize( const createItemData = memoize(
( (
@@ -27,10 +21,12 @@ const createItemData = memoize(
itemWidth, itemWidth,
route, route,
handlePlayQueueAdd, handlePlayQueueAdd,
handleFavorite,
) => ({ ) => ({
cardRows, cardRows,
columnCount, columnCount,
display, display,
handleFavorite,
handlePlayQueueAdd, handlePlayQueueAdd,
itemCount, itemCount,
itemData, itemData,
@@ -56,6 +52,7 @@ export const VirtualGridWrapper = ({
columnCount, columnCount,
rowCount, rowCount,
initialScrollOffset, initialScrollOffset,
handleFavorite,
handlePlayQueueAdd, handlePlayQueueAdd,
itemData, itemData,
route, route,
@@ -65,6 +62,7 @@ export const VirtualGridWrapper = ({
cardRows: CardRow<Album | AlbumArtist | Artist>[]; cardRows: CardRow<Album | AlbumArtist | Artist>[];
columnCount: number; columnCount: number;
display: ListDisplayType; display: ListDisplayType;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any[]; itemData: any[];
itemGap: number; itemGap: number;
@@ -87,6 +85,7 @@ export const VirtualGridWrapper = ({
itemWidth, itemWidth,
route, route,
handlePlayQueueAdd, handlePlayQueueAdd,
handleFavorite,
); );
const memoizedOnScroll = createScrollHandler(onScroll); const memoizedOnScroll = createScrollHandler(onScroll);
@@ -11,8 +11,9 @@ import debounce from 'lodash/debounce';
import type { FixedSizeListProps } from 'react-window'; import type { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader'; import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper'; import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
import type { CardRoute, CardRow, LibraryItem, PlayQueueAddOptions } from '/@/renderer/types'; import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types'; import { ListDisplayType } from '/@/renderer/types';
import { LibraryItem } from '/@/renderer/api/types';
export type VirtualInfiniteGridRef = { export type VirtualInfiniteGridRef = {
resetLoadMoreItemsCache: () => void; resetLoadMoreItemsCache: () => void;
@@ -24,22 +25,16 @@ interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSi
cardRows: CardRow<any>[]; cardRows: CardRow<any>[];
display?: ListDisplayType; display?: ListDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>; fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemGap: number; itemGap: number;
itemSize: number; itemSize: number;
itemType: LibraryItem; itemType: LibraryItem;
loading?: boolean;
minimumBatchSize?: number; minimumBatchSize?: number;
route?: CardRoute; route?: CardRoute;
} }
const constrainWidth = (width: number) => {
if (width < 1920) {
return width;
}
return 1920;
};
export const VirtualInfiniteGrid = forwardRef( export const VirtualInfiniteGrid = forwardRef(
( (
{ {
@@ -54,28 +49,30 @@ export const VirtualInfiniteGrid = forwardRef(
handlePlayQueueAdd, handlePlayQueueAdd,
minimumBatchSize, minimumBatchSize,
fetchFn, fetchFn,
loading,
initialScrollOffset, initialScrollOffset,
handleFavorite,
height, height,
width, width,
}: VirtualGridProps, }: VirtualGridProps,
ref: Ref<VirtualInfiniteGridRef>, ref: Ref<VirtualInfiniteGridRef>,
) => { ) => {
const [itemData, setItemData] = useState<any[]>([]);
const listRef = useRef<any>(null); const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(null); const loader = useRef<InfiniteLoader>(null);
const [itemData, setItemData] = useState<any[]>([]);
const { itemHeight, rowCount, columnCount } = useMemo(() => { const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = Math.floor( const itemsPerRow = itemSize;
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2), const widthPerItem = Number(width) / itemsPerRow;
); const itemHeight = widthPerItem + cardRows.length * 26;
return { return {
columnCount: itemsPerRow, columnCount: itemsPerRow,
itemHeight: itemSize! + cardRows.length * 22 + itemGap, itemHeight,
itemWidth: itemSize! + itemGap,
rowCount: Math.ceil(itemCount / itemsPerRow), rowCount: Math.ceil(itemCount / itemsPerRow),
}; };
}, [cardRows.length, itemCount, itemGap, itemSize, width]); }, [cardRows.length, itemCount, itemSize, width]);
const isItemLoaded = useCallback( const isItemLoaded = useCallback(
(index: number) => { (index: number) => {
@@ -111,7 +108,7 @@ export const VirtualInfiniteGrid = forwardRef(
setItemData(newData); setItemData(newData);
}, },
[columnCount, fetchFn, itemData], [columnCount, fetchFn, itemData, setItemData],
); );
const debouncedLoadMoreItems = debounce(loadMoreItems, 500); const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
@@ -120,52 +117,57 @@ export const VirtualInfiniteGrid = forwardRef(
resetLoadMoreItemsCache: () => { resetLoadMoreItemsCache: () => {
if (loader.current) { if (loader.current) {
loader.current.resetloadMoreItemsCache(false); loader.current.resetloadMoreItemsCache(false);
setItemData(() => []); setItemData([]);
} }
}, },
scrollTo: (index: number) => { scrollTo: (index: number) => {
listRef.current.scrollToItem(index); listRef?.current?.scrollToItem(index);
}, },
setItemData: (data: any[]) => { setItemData: (data: any[]) => {
setItemData(data); setItemData(data);
}, },
})); }));
if (loading) return null;
return ( return (
<InfiniteLoader <>
ref={loader} <InfiniteLoader
isItemLoaded={(index) => isItemLoaded(index)} ref={loader}
itemCount={itemCount || 0} isItemLoaded={(index) => isItemLoaded(index)}
loadMoreItems={debouncedLoadMoreItems} itemCount={itemCount || 0}
minimumBatchSize={minimumBatchSize} loadMoreItems={debouncedLoadMoreItems}
threshold={30} minimumBatchSize={minimumBatchSize}
> threshold={30}
{({ onItemsRendered, ref: infiniteLoaderRef }) => ( >
<VirtualGridWrapper {({ onItemsRendered, ref: infiniteLoaderRef }) => (
cardRows={cardRows} <VirtualGridWrapper
columnCount={columnCount} cardRows={cardRows}
display={display || ListDisplayType.CARD} columnCount={columnCount}
handlePlayQueueAdd={handlePlayQueueAdd} display={display || ListDisplayType.CARD}
height={height} handleFavorite={handleFavorite}
initialScrollOffset={initialScrollOffset} handlePlayQueueAdd={handlePlayQueueAdd}
itemCount={itemCount || 0} height={height}
itemData={itemData} initialScrollOffset={initialScrollOffset}
itemGap={itemGap} itemCount={itemCount || 0}
itemHeight={itemHeight + itemGap / 2} itemData={itemData}
itemType={itemType} itemGap={itemGap}
itemWidth={itemSize} itemHeight={itemHeight}
refInstance={(list) => { itemType={itemType}
infiniteLoaderRef(list); itemWidth={itemSize}
listRef.current = list; refInstance={(list) => {
}} infiniteLoaderRef(list);
route={route} listRef.current = list;
rowCount={rowCount} }}
width={width} route={route}
onItemsRendered={onItemsRendered} rowCount={rowCount}
onScroll={onScroll} width={width}
/> onItemsRendered={onItemsRendered}
)} onScroll={onScroll}
</InfiniteLoader> />
)}
</InfiniteLoader>
</>
); );
}, },
); );
@@ -25,14 +25,14 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
<Text <Text
$secondary $secondary
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{value?.map((item: Artist | AlbumArtist, index: number) => ( {value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}> <React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && ( {index > 0 && (
<Text <Text
$secondary $secondary
size="sm" size="md"
style={{ display: 'inline-block' }} style={{ display: 'inline-block' }}
> >
, ,
@@ -43,8 +43,8 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
$secondary $secondary
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size="sm" size="md"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: item.id, albumArtistId: item.id,
})} })}
> >
@@ -25,14 +25,14 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
<Text <Text
$secondary $secondary
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{value?.map((item: Artist | AlbumArtist, index: number) => ( {value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}> <React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && ( {index > 0 && (
<Text <Text
$secondary $secondary
size="sm" size="md"
style={{ display: 'inline-block' }} style={{ display: 'inline-block' }}
> >
, ,
@@ -43,7 +43,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
$secondary $secondary
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size="sm" size="md"
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: item.id, artistId: item.id,
})} })}
@@ -5,7 +5,6 @@ import { motion } from 'framer-motion';
import { RiAlbumFill } from 'react-icons/ri'; import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router'; import { generatePath } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components'; import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types'; import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text'; import { Text } from '/@/renderer/components/text';
@@ -23,6 +22,7 @@ const CellContainer = styled(motion.div)<{ height: number }>`
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
letter-spacing: 0.5px;
`; `;
const ImageWrapper = styled.div` const ImageWrapper = styled.div`
@@ -41,10 +41,8 @@ const MetadataWrapper = styled.div`
width: 100%; width: 100%;
`; `;
const StyledImage = styled(SimpleImg)` const StyledImage = styled.img`
img { object-fit: cover;
object-fit: cover;
}
`; `;
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => { export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
@@ -105,14 +103,14 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
<MetadataWrapper> <MetadataWrapper>
<Text <Text
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{value.name} {value.name}
</Text> </Text>
<Text <Text
$secondary $secondary
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{artists?.length ? ( {artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => ( artists.map((artist: Artist | AlbumArtist, index: number) => (
@@ -123,9 +121,9 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
$secondary $secondary
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size="sm" size="md"
sx={{ width: 'fit-content' }} sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id, albumArtistId: artist.id,
})} })}
> >
@@ -0,0 +1,64 @@
/* 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 { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
const createMutation = useCreateFavorite({});
const deleteMutation = useDeleteFavorite({});
const handleToggleFavorite = () => {
const newFavoriteValue = !value;
if (newFavoriteValue) {
createMutation.mutate(
{
query: {
id: [data.id],
type: data.itemType,
},
},
{
onSuccess: () => {
node.setData({ ...data, userFavorite: newFavoriteValue });
},
},
);
} else {
deleteMutation.mutate(
{
query: {
id: [data.id],
type: data.itemType,
},
},
{
onSuccess: () => {
node.setData({ ...data, userFavorite: newFavoriteValue });
},
},
);
}
};
return (
<CellContainer position="center">
<Button
compact
sx={{
svg: {
fill: !value
? 'var(--main-fg-secondary) !important'
: 'var(--primary-color) !important',
},
}}
variant="subtle"
onClick={handleToggleFavorite}
>
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
</Button>
</CellContainer>
);
};
@@ -25,6 +25,7 @@ export const CellContainer = styled(motion.div)<{ position?: 'left' | 'center' |
: 'flex-start'}; : 'flex-start'};
width: 100%; width: 100%;
height: 100%; height: 100%;
letter-spacing: 0.5px;
`; `;
type Options = { type Options = {
@@ -60,7 +61,7 @@ export const GenericCell = (
$secondary={!primary} $secondary={!primary}
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size="sm" size="md"
to={displayedValue.link} to={displayedValue.link}
> >
{isLink ? displayedValue.value : displayedValue} {isLink ? displayedValue.value : displayedValue}
@@ -69,7 +70,7 @@ export const GenericCell = (
<Text <Text
$secondary={!primary} $secondary={!primary}
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{displayedValue} {displayedValue}
</Text> </Text>
@@ -11,14 +11,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
<Text <Text
$secondary $secondary
overflow="hidden" overflow="hidden"
size="sm" size="md"
> >
{value?.map((item: Artist | AlbumArtist, index: number) => ( {value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}> <React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && ( {index > 0 && (
<Text <Text
$secondary $secondary
size="sm" size="md"
style={{ display: 'inline-block' }} style={{ display: 'inline-block' }}
> >
, ,
@@ -29,7 +29,7 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
$secondary $secondary
component={Link} component={Link}
overflow="hidden" overflow="hidden"
size="sm" size="md"
to="/" to="/"
> >
{item.name || '—'} {item.name || '—'}
@@ -0,0 +1,59 @@
/* 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 { useSetRating } from '/@/renderer/features/shared';
export const RatingCell = ({ value, node }: ICellRendererParams) => {
const updateRatingMutation = useSetRating({});
const handleUpdateRating = (rating: number) => {
if (!value) return;
updateRatingMutation.mutate(
{
query: {
item: [value],
rating,
},
serverId: value?.serverId,
},
{
onSuccess: () => {
node.setData({ ...node.data, userRating: rating });
},
},
);
};
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
updateRatingMutation.mutate(
{
query: {
item: [value],
rating: 0,
},
serverId: value?.serverId,
},
{
onSuccess: () => {
node.setData({ ...node.data, userRating: 0 });
},
},
);
};
return (
<CellContainer position="center">
<Rating
size="xs"
value={value?.userRating}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</CellContainer>
);
};
@@ -2,9 +2,11 @@ import type { ReactNode } from 'react';
import type { IHeaderParams } from '@ag-grid-community/core'; import type { IHeaderParams } from '@ag-grid-community/core';
import { AiOutlineNumber } from 'react-icons/ai'; import { AiOutlineNumber } from 'react-icons/ai';
import { FiClock } from 'react-icons/fi'; import { FiClock } from 'react-icons/fi';
import { RiHeartLine, RiStarLine } from 'react-icons/ri';
import styled from 'styled-components'; import styled from 'styled-components';
import { _Text } from '/@/renderer/components/text';
type Presets = 'duration' | 'rowIndex'; type Presets = 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
type Options = { type Options = {
children?: ReactNode; children?: ReactNode;
@@ -12,7 +14,7 @@ type Options = {
preset?: Presets; preset?: Presets;
}; };
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>` const HeaderWrapper = styled.div<{ position: Options['position'] }>`
display: flex; display: flex;
justify-content: ${(props) => justify-content: ${(props) =>
props.position === 'right' props.position === 'right'
@@ -25,17 +27,63 @@ const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
text-transform: uppercase; text-transform: uppercase;
`; `;
const headerPresets = { duration: <FiClock size={15} />, rowIndex: <AiOutlineNumber size={15} /> }; const TextHeaderWrapper = styled(_Text)<{ position: Options['position'] }>`
width: 100%;
color: var(--ag-header-foreground-color);
font-weight: 500;
text-align: ${(props) =>
props.position === 'right'
? 'flex-end'
: props.position === 'center'
? 'center'
: 'flex-start'};
text-transform: uppercase;
`;
const headerPresets = {
duration: (
<FiClock
color="var(--ag-header-foreground-color)"
size="1em"
/>
),
rowIndex: (
<AiOutlineNumber
color="var(--ag-header-foreground-color)"
size="1em"
/>
),
userFavorite: (
<RiHeartLine
color="var(--ag-header-foreground-color)"
size="1em"
/>
),
userRating: (
<RiStarLine
color="var(--ag-header-foreground-color)"
size="1em"
/>
),
};
export const GenericTableHeader = ( export const GenericTableHeader = (
{ displayName }: IHeaderParams, { displayName }: IHeaderParams,
{ preset, children, position }: Options, { preset, children, position }: Options,
) => { ) => {
if (preset) { if (preset) {
return <HeaderWrapper position={position || 'left'}>{headerPresets[preset]}</HeaderWrapper>; return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
} }
return <HeaderWrapper position={position || 'left'}>{children || displayName}</HeaderWrapper>; return (
<TextHeaderWrapper
overflow="hidden"
position={position}
weight={500}
>
{children || displayName}
</TextHeaderWrapper>
);
}; };
GenericTableHeader.defaultProps = { GenericTableHeader.defaultProps = {

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