Compare commits

...

257 Commits

Author SHA1 Message Date
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
267 changed files with 22073 additions and 10840 deletions
+4 -27
View File
@@ -4,18 +4,6 @@ about: You're having technical issues. 🐞
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
<!--- What should have happened? -->
@@ -23,6 +11,8 @@ labels: 'bug'
## Current Behavior
<!--- 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
@@ -44,24 +34,11 @@ labels: 'bug'
## Context
<!--- 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
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Node version :
- electron-react-boilerplate version or branch :
- Application version :
- Operating System and version :
- Link to your project :
<!---
❗️❗️ 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
-->
- Node version (if developing locally) :
+3 -13
View File
@@ -4,16 +4,6 @@ about: Ask a question.❓
labels: 'question'
---
## Summary
<!-- What do you need help with? -->
<!---
❗️❗️ 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
-->
<!-- Question issues will be closed. -->
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
+3 -9
View File
@@ -1,15 +1,9 @@
---
name: Feature request
about: You want something added to the boilerplate. 🎉
about: Request a feature to be added to Feishin 🎉
labels: 'enhancement'
---
<!---
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
## What do you want to be added?
Donations will ensure the following:
🔨 Long term maintenance of the project
🛣 Progress on the roadmap
🐛 Quick responses to bug reports and help requests
-->
## Additional context
+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
@@ -35,5 +35,5 @@ jobs:
command: |
npm run postinstall
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
+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>
</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
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
### 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)
- ~~[Airsonic](https://github.com/airsonic/airsonic)~~
- ~~[Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)~~
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- ~~[Gonic](https://github.com/sentriz/gonic)~~
- ~~[Astiga](https://asti.ga/)~~
- ~~[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

+2043 -2799
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.0.1-alpha3",
"version": "0.0.1-alpha6",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -11,6 +11,7 @@
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:styles": "npx stylelint **/*.tsx",
"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",
"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",
@@ -50,6 +51,7 @@
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1",
"mac": {
"target": {
"target": "default",
@@ -157,6 +159,7 @@
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
@@ -169,7 +172,6 @@
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-slider": "^1.3.1",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
@@ -188,11 +190,10 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^21.2.0",
"electron-builder": "^23.0.3",
"electron": "^22.3.1",
"electron-builder": "^24.0.0-alpha.13",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electron-rebuild": "^3.2.7",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
@@ -253,16 +254,16 @@
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^5.9.5",
"@mantine/dates": "^5.9.5",
"@mantine/dropzone": "^5.9.5",
"@mantine/form": "^5.9.5",
"@mantine/hooks": "^5.9.5",
"@mantine/modals": "^5.9.5",
"@mantine/notifications": "^5.9.5",
"@mantine/spotlight": "^5.9.5",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"@mantine/core": "^6.0.0",
"@mantine/dates": "^6.0.0",
"@mantine/dropzone": "^6.0.0",
"@mantine/form": "^6.0.0",
"@mantine/hooks": "^6.0.0",
"@mantine/modals": "^6.0.0",
"@mantine/notifications": "^6.0.0",
"@mantine/utils": "^6.0.0",
"@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.24.4",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
@@ -292,7 +293,6 @@
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-slider": "^2.0.4",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.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",
"version": "0.0.1-alpha3",
"version": "0.0.1-alpha6",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -12,6 +12,11 @@
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"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"
}
+50 -92
View File
@@ -1,140 +1,98 @@
import { ipcMain } from 'electron';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { store } from '../settings';
import { getMainWindow } from '../../../main';
import { getMpvInstance } from '../../../main';
import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv';
const BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const DEFAULT_MPV_PARAMETERS = () => {
const parameters = [];
if (
!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');
}
function wait(timeout: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('resolved');
}, timeout);
});
}
if (
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
!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);
ipcMain.on('player-start', async () => {
await getMpvInstance()?.play();
});
// Starts the player
ipcMain.on('player-play', async () => {
await mpv.play();
await getMpvInstance()?.play();
});
// Pauses the player
ipcMain.on('player-pause', async () => {
await mpv.pause();
await getMpvInstance()?.pause();
});
// Stops the player
ipcMain.on('player-stop', async () => {
await mpv.stop();
await getMpvInstance()?.stop();
});
// Goes to the next track in the playlist
ipcMain.on('player-next', async () => {
await mpv.next();
await getMpvInstance()?.next();
});
// Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => {
await mpv.prev();
await getMpvInstance()?.prev();
});
// Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => {
await mpv.seek(time);
await getMpvInstance()?.seek(time);
});
// Seeks to the given time in seconds
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
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
if (data.queue.current) {
await mpv.load(data.queue.current.streamUrl, 'replace');
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
return;
}
if (data.queue.next) {
await mpv.load(data.queue.next.streamUrl, 'append');
let complete = false;
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
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) {
await mpv.playlistRemove(1);
await getMpvInstance()?.playlistRemove(1);
}
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
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await mpv.playlistRemove(0);
await getMpvInstance()?.playlistRemove(0);
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)
ipcMain.on('player-volume', async (_event, value: number) => {
await mpv.volume(value);
await getMpvInstance()?.volume(value);
});
// Toggles the mute status
ipcMain.on('player-mute', async () => {
await mpv.mute();
await getMpvInstance()?.mute();
});
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.
*/
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 log from 'electron-log';
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 { store } from './features/core/settings/index';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './utils';
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import './features';
declare module 'node-mpv';
export default class AppUpdater {
constructor() {
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 tray: Tray | null = null;
let exitFromTray = false;
let forceQuit = false;
if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support');
@@ -53,19 +77,111 @@ const installExtensions = async () => {
.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 () => {
if (isDevelopment) {
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({
frame: false,
height: 900,
@@ -81,6 +197,7 @@ const createWindow = async () => {
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
webSecurity: store.get('ignore_cors') ? false : undefined,
},
width: 1440,
});
@@ -89,6 +206,10 @@ const createWindow = async () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-dev-tools', () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-maximize', () => {
mainWindow?.maximize();
});
@@ -134,6 +255,7 @@ const createWindow = async () => {
mainWindow.minimize();
} else {
mainWindow.show();
createWinThumbarButtons();
}
});
@@ -141,6 +263,33 @@ const createWindow = async () => {
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);
menuBuilder.buildMenu();
@@ -150,30 +299,121 @@ const createWindow = async () => {
return { action: 'deny' };
});
// Remove this if your app does not use auto updates
// eslint-disable-next-line
new AppUpdater();
if (store.get('disable_auto_updates') !== true) {
// eslint-disable-next-line
new AppUpdater();
}
};
/**
* Add event listeners...
*/
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
export const getMainWindow = () => {
return mainWindow;
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const prefetchPlaylistParams = [
'--prefetch-playlist=no',
'--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', () => {
mainWindow?.webContents.send('renderer-player-quit');
getMpvInstance()?.stop();
});
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
getMpvInstance()?.quit();
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
globalShortcut.unregisterAll();
if (process.platform !== 'darwin') {
if (isMacOS()) {
mainWindow = null;
} else {
app.quit();
}
});
@@ -182,6 +422,7 @@ app
.whenReady()
.then(() => {
createWindow();
createTray();
app.on('activate', () => {
// 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.
+5 -97
View File
@@ -1,109 +1,17 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '../renderer/store';
import { contextBridge } from 'electron';
import { browser } from './preload/browser';
import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings';
import { mpris } from './preload/mpris';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', {
browser,
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,
mpris,
mpvPlayer,
mpvPlayerListener,
utils,
});
+5
View File
@@ -13,7 +13,12 @@ const unmaximize = () => {
ipcRenderer.send('window-unmaximize');
};
const devtools = () => {
ipcRenderer.send('window-dev-tools');
};
export const browser = {
devtools,
exit,
maximize,
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 { 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) => {
ipcRenderer.send('player-auto-next', data);
};
@@ -8,36 +17,47 @@ const autoNext = (data: PlayerData) => {
const currentTime = () => {
ipcRenderer.send('player-current-time');
};
const mute = () => {
ipcRenderer.send('player-mute');
};
const next = () => {
ipcRenderer.send('player-next');
};
const pause = () => {
ipcRenderer.send('player-pause');
};
const play = () => {
ipcRenderer.send('player-play');
};
const previous = () => {
ipcRenderer.send('player-previous');
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
};
const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData) => {
ipcRenderer.send('player-set-queue', data);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
};
const stop = () => {
ipcRenderer.send('player-stop');
};
const volume = (value: number) => {
ipcRenderer.send('player-volume', value);
};
@@ -91,8 +111,10 @@ export const mpvPlayer = {
play,
previous,
quit,
restart,
seek,
seekTo,
setProperties,
setQueue,
setQueueNext,
stop,
+7
View File
@@ -0,0 +1,7 @@
import { isMacOS, isWindows, isLinux } from '../utils';
export const utils = {
isLinux,
isMacOS,
isWindows,
};
+83 -3
View File
@@ -16,7 +16,6 @@ import type {
RawAlbumArtistListResponse,
RatingArgs,
RawRatingResponse,
FavoriteArgs,
RawFavoriteResponse,
GenreListArgs,
RawGenreListResponse,
@@ -35,11 +34,24 @@ import type {
RawArtistListResponse,
UpdatePlaylistArgs,
RawUpdatePlaylistResponse,
UserListArgs,
RawUserListResponse,
FavoriteArgs,
TopSongListArgs,
RawTopSongListResponse,
AddToPlaylistArgs,
RawAddToPlaylistResponse,
RemoveFromPlaylistArgs,
RawRemoveFromPlaylistResponse,
ScrobbleArgs,
RawScrobbleResponse,
} from '/@/renderer/api/types';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
import { ServerListItem } from '/@/renderer/types';
export type ControllerEndpoint = Partial<{
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
clearPlaylist: () => void;
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
@@ -50,6 +62,7 @@ export type ControllerEndpoint = Partial<{
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
getArtistDetail: () => void;
getArtistInfo: (args: any) => void;
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
getFavoritesList: () => void;
getFolderItemList: () => void;
@@ -62,6 +75,10 @@ export type ControllerEndpoint = Partial<{
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
}>;
@@ -74,6 +91,7 @@ type ApiController = {
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jellyfinApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
@@ -84,6 +102,7 @@ const endpoints: ApiController = {
getAlbumDetail: jellyfinApi.getAlbumDetail,
getAlbumList: jellyfinApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: jellyfinApi.getArtistList,
getFavoritesList: undefined,
getFolderItemList: undefined,
@@ -96,10 +115,15 @@ const endpoints: ApiController = {
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getTopSongs: jellyfinApi.getTopSongList,
getUserList: undefined,
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
scrobble: jellyfinApi.scrobble,
updatePlaylist: jellyfinApi.updatePlaylist,
updateRating: undefined,
},
navidrome: {
addToPlaylist: navidromeApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: subsonicApi.createFavorite,
createPlaylist: navidromeApi.createPlaylist,
@@ -110,6 +134,7 @@ const endpoints: ApiController = {
getAlbumDetail: navidromeApi.getAlbumDetail,
getAlbumList: navidromeApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@@ -122,6 +147,10 @@ const endpoints: ApiController = {
getPlaylistSongList: navidromeApi.getPlaylistSongList,
getSongDetail: navidromeApi.getSongDetail,
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
scrobble: subsonicApi.scrobble,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@@ -136,6 +165,7 @@ const endpoints: ApiController = {
getAlbumDetail: subsonicApi.getAlbumDetail,
getAlbumList: subsonicApi.getAlbumList,
getArtistDetail: undefined,
getArtistInfo: undefined,
getArtistList: undefined,
getFavoritesList: undefined,
getFolderItemList: undefined,
@@ -147,13 +177,16 @@ const endpoints: ApiController = {
getPlaylistList: undefined,
getSongDetail: undefined,
getSongList: undefined,
getTopSongs: subsonicApi.getTopSongList,
getUserList: undefined,
scrobble: subsonicApi.scrobble,
updatePlaylist: undefined,
updateRating: undefined,
},
};
const apiController = (endpoint: keyof ControllerEndpoint) => {
const serverType = useAuthStore.getState().currentServer?.type;
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' });
@@ -193,6 +226,12 @@ const getGenreList = async (args: GenreListArgs) => {
return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args);
};
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
return (apiController('getAlbumArtistDetail') as ControllerEndpoint['getAlbumArtistDetail'])?.(
args,
);
};
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args);
};
@@ -217,6 +256,14 @@ const deletePlaylist = async (args: DeletePlaylistArgs) => {
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
};
const addToPlaylist = async (args: AddToPlaylistArgs) => {
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
};
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
};
@@ -227,9 +274,37 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
);
};
const getUserList = async (args: UserListArgs) => {
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
};
const createFavorite = async (args: FavoriteArgs) => {
return (apiController('createFavorite') as ControllerEndpoint['createFavorite'])?.(args);
};
const deleteFavorite = async (args: FavoriteArgs) => {
return (apiController('deleteFavorite') as ControllerEndpoint['deleteFavorite'])?.(args);
};
const updateRating = async (args: RatingArgs) => {
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
};
const getTopSongList = async (args: TopSongListArgs) => {
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
};
const scrobble = async (args: ScrobbleArgs) => {
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
};
export const controller = {
addToPlaylist,
createFavorite,
createPlaylist,
deleteFavorite,
deletePlaylist,
getAlbumArtistDetail,
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
@@ -240,5 +315,10 @@ export const controller = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
updatePlaylist,
updateRating,
};
+244 -37
View File
@@ -1,6 +1,8 @@
import ky from 'ky';
import { nanoid } from 'nanoid/non-secure';
import type {
import {
JFAddToPlaylist,
JFAddToPlaylistParams,
JFAlbum,
JFAlbumArtist,
JFAlbumArtistDetail,
@@ -27,12 +29,16 @@ import type {
JFPlaylistDetailResponse,
JFPlaylistList,
JFPlaylistListResponse,
JFRemoveFromPlaylist,
JFRemoveFromPlaylistParams,
JFSong,
JFSongList,
JFSongListParams,
JFSongListResponse,
JFSongListSort,
JFCollectionType,
JFSortOrder,
} from '/@/renderer/api/jellyfin.types';
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
import {
Album,
AlbumArtist,
@@ -63,16 +69,27 @@ import {
albumArtistListSortMap,
UpdatePlaylistArgs,
UpdatePlaylistResponse,
LibraryItem,
RemoveFromPlaylistArgs,
AddToPlaylistArgs,
ScrobbleArgs,
RawScrobbleResponse,
TopSongListArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import packageJson from '../../../package.json';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const getCommaDelimitedString = (value: string[]) => {
return value.join(',');
};
const api = ky.create({});
const api = ky.create({
mode: IGNORE_CORS ? 'cors' : undefined,
});
const authenticate = async (
url: string,
@@ -86,8 +103,7 @@ const authenticate = async (
const data = await ky
.post(`${cleanServerUrl}/users/authenticatebyname`, {
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
},
json: {
pw: body.password,
@@ -139,7 +155,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
const { query, server, signal } = args;
const searchParams = {
fields: 'Genres',
fields: 'Genres, Overview',
};
const data = await api
@@ -151,7 +167,16 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbu
})
.json<JFAlbumArtistDetailResponse>();
return data;
const similarArtists = await api
.get(`artists/${query.id}/similar`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams({ limit: 10 }),
signal,
})
.json<JFAlbumArtistListResponse>();
return { ...data, similarArtists: { items: similarArtists.Items } };
};
// const getAlbumArtistAlbums = () => {
@@ -304,6 +329,36 @@ const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
};
};
const getTopSongList = async (args: TopSongListArgs): Promise<JFSongList> => {
const { signal, server, query } = args;
const searchParams: JFSongListParams = {
artistIds: query.artistId,
fields: 'Genres, DateCreated, MediaSources, ParentId',
includeItemTypes: 'Audio',
limit: query.limit,
recursive: true,
sortBy: JFSongListSort.COMMUNITY_RATING,
sortOrder: JFSortOrder.DESC,
userId: server?.userId || '',
};
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: 0,
totalRecordCount: data.TotalRecordCount,
};
};
const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
const { query, server, signal } = args;
@@ -352,6 +407,45 @@ const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<JFAddToPlaylist> => {
const { query, body, server, signal } = args;
const searchParams: JFAddToPlaylistParams = {
ids: body.songId,
userId: server?.userId || '',
};
await api
.post(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<JFRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: JFRemoveFromPlaylistParams = {
entryIds: query.songId,
};
await api
.delete(`playlists/${query.id}/items`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<JFPlaylistDetailResponse>();
return null;
};
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
const { query, server, signal } = args;
@@ -433,26 +527,26 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList>
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const { body, server } = args;
const body = {
const json = {
MediaType: 'Audio',
Name: query.name,
Overview: query.comment || '',
Name: body.name,
Overview: body.comment || '',
UserId: server?.userId,
};
const data = await api
.post('playlists', {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: body,
json,
prefixUrl: server?.url,
})
.json<JFCreatePlaylistResponse>();
return {
id: data.Id,
name: query.name,
name: body.name,
};
};
@@ -497,29 +591,110 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<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,
});
for (const id of query.id) {
await api.post(`users/${server?.userId}/favoriteitems/${id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
}
return {
id: query.id,
type: query.type,
};
};
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,
});
for (const id of query.id) {
await api.delete(`users/${server?.userId}/favoriteitems/${id}`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
prefixUrl: server?.url,
});
}
return {
id: query.id,
type: query.type,
};
};
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
const { query, server } = 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)
api.post(`sessions/playing/stopped`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'start') {
await api.post(`sessions/playing`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'pause') {
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
EventName: query.event,
IsPaused: true,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
if (query.event === 'unpause') {
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
EventName: query.event,
IsPaused: false,
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
}
await api.post(`sessions/playing/progress`, {
headers: { 'X-MediaBrowser-Token': server?.credential },
json: {
ItemId: query.id,
PositionTicks: position,
},
prefixUrl: server?.url,
});
return null;
};
const getStreamUrl = (args: {
container?: string;
deviceId: string;
@@ -581,7 +756,7 @@ const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: numbe
};
const getSongCoverArtUrl = (args: { baseUrl: string; item: JFSong; size: number }) => {
const size = args.size ? args.size : 300;
const size = args.size ? args.size : 100;
if (!args.item.ImageTags?.Primary) {
return null;
@@ -635,13 +810,18 @@ const normalizeSong = (
): Song => {
return {
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
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, name: entry.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,
@@ -650,17 +830,18 @@ const normalizeSong = (
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,
imageUrl: getSongCoverArtUrl({ baseUrl: server.url, item, size: imageSize || 100 }),
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.Name,
note: null,
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,
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,
@@ -671,9 +852,10 @@ const normalizeSong = (
server,
}),
trackNumber: item.IndexNumber,
type: ServerType.JELLYFIN,
uniqueId: nanoid(),
updatedAt: item.DateCreated,
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
userRating: null,
};
};
@@ -682,9 +864,10 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, 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,
@@ -697,19 +880,21 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
size: imageSize || 300,
}),
isCompilation: null,
isFavorite: item.UserData?.IsFavorite || false,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
rating: null,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear,
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,
};
};
@@ -730,12 +915,26 @@ const normalizeAlbumArtist = (
item,
size: imageSize || 300,
}),
isFavorite: item.UserData.IsFavorite || false,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.Name,
playCount: item.UserData.PlayCount,
rating: null,
serverId: server.id,
serverType: ServerType.JELLYFIN,
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,
})),
songCount: null,
userFavorite: item.UserData.IsFavorite || false,
userRating: null,
};
};
@@ -759,13 +958,17 @@ const normalizePlaylist = (
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,
userId: null,
username: null,
sync: null,
};
};
@@ -820,6 +1023,7 @@ const normalizePlaylist = (
// };
export const jellyfinApi = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
@@ -836,6 +1040,9 @@ export const jellyfinApi = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
removeFromPlaylist,
scrobble,
updatePlaylist,
};
+25
View File
@@ -59,6 +59,25 @@ export type JFSongList = {
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 {
Items: JFPlaylist[];
}
@@ -173,6 +192,10 @@ export type JFAlbumArtist = {
PlaybackPositionTicks: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFArtist = {
@@ -248,6 +271,7 @@ export type JFSong = {
MediaType: string;
Name: string;
ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
@@ -543,6 +567,7 @@ export enum 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',
+172 -33
View File
@@ -37,9 +37,19 @@ import type {
NDPlaylistSongListResponse,
NDPlaylistSongList,
NDPlaylistSong,
NDUserList,
NDUserListResponse,
NDUserListParams,
NDUser,
NDAddToPlaylist,
NDAddToPlaylistBody,
NDAddToPlaylistResponse,
NDRemoveFromPlaylistParams,
NDRemoveFromPlaylistResponse,
NDRemoveFromPlaylist,
} from '/@/renderer/api/navidrome.types';
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import type {
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
Album,
Song,
AuthenticationResponse,
@@ -60,18 +70,25 @@ import type {
Playlist,
UpdatePlaylistResponse,
UpdatePlaylistArgs,
} from '/@/renderer/api/types';
import {
UserListArgs,
userListSortMap,
playlistListSortMap,
albumArtistListSortMap,
songListSortMap,
albumListSortMap,
sortOrderMap,
User,
LibraryItem,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const api = ky.create({
hooks: {
@@ -107,6 +124,7 @@ const api = ky.create({
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const authenticate = async (
@@ -132,6 +150,34 @@ const authenticate = async (
};
};
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
const { query, server, signal } = args;
const searchParams: NDUserListParams = {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: userListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
...query.ndParams,
};
const res = await api.get('api/user', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
const data = await res.json<NDUserListResponse>();
const itemCount = res.headers.get('x-total-count');
return {
items: data,
startIndex: query?.startIndex || 0,
totalRecordCount: Number(itemCount),
};
};
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const { server, signal } = args;
@@ -149,6 +195,15 @@ const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
const { query, server, signal } = args;
const artistInfo = await subsonicApi.getArtistInfo({
query: {
artistId: query.id,
limit: 15,
},
server,
signal,
});
const data = await api
.get(`api/artist/${query.id}`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
@@ -157,7 +212,7 @@ const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbu
})
.json<NDAlbumArtistDetailResponse>();
return { ...data };
return { ...data, similarArtists: artistInfo.similarArtist };
};
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
@@ -226,6 +281,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: query.artistIds?.[0],
name: query.searchTerm,
...query.ndParams,
};
@@ -293,12 +349,14 @@ const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
};
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
const { query, server } = args;
const { body, server } = args;
const json: NDCreatePlaylistParams = {
comment: query.comment,
name: query.name,
public: query.public || false,
comment: body.comment,
name: body.name,
...body.ndParams,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
};
const data = await api
@@ -311,7 +369,7 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistR
return {
id: data.id,
name: query.name,
name: body.name,
};
};
@@ -321,7 +379,11 @@ const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistR
const json: NDUpdatePlaylistParams = {
comment: body.comment || '',
name: body.name,
public: body.public || false,
ownerId: body.ndParams?.ownerId || undefined,
ownerName: body.ndParams?.owner || undefined,
public: body.ndParams?.public || false,
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
sync: body.ndParams?.sync || undefined,
};
const data = await api
@@ -357,8 +419,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList>
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,
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
...query.ndParams,
};
@@ -422,6 +484,44 @@ const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlayli
};
};
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
const { query, body, server, signal } = args;
const json: NDAddToPlaylistBody = {
ids: body.songId,
};
await api
.post(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
json,
prefixUrl: server?.url,
signal,
})
.json<NDAddToPlaylistResponse>();
return null;
};
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
const { query, server, signal } = args;
const searchParams: NDRemoveFromPlaylistParams = {
id: query.songId,
};
await api
.delete(`api/playlist/${query.id}/tracks`, {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<NDRemoveFromPlaylistResponse>();
return null;
};
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
@@ -451,10 +551,12 @@ const normalizeSong = (
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;
}
@@ -463,20 +565,21 @@ const normalizeSong = (
baseUrl: server.url,
coverArtId: id,
credential: server.credential,
size: imageSize || 300,
size: imageSize || 100,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumArtists: [{ id: item.artistId, name: item.artist }],
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
albumId: item.albumId,
artistName: item.artist,
artists: [{ id: item.artistId, name: 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],
@@ -486,21 +589,23 @@ const normalizeSong = (
id,
imagePlaceholderUrl,
imageUrl,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate : null,
itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.title,
note: item.comment ? item.comment : null,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
trackNumber: item.trackNumber,
type: ServerType.NAVIDROME,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred || false,
userRating: item.rating || null,
};
};
@@ -517,8 +622,8 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
albumArtists: [{ id: item.albumArtistId, name: item.albumArtist }],
artists: [{ id: item.artistId, name: item.artist }],
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,
@@ -527,37 +632,51 @@ const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: numbe
imagePlaceholderUrl,
imageUrl,
isCompilation: item.compilation,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
rating: item.rating,
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
releaseYear: item.minYear,
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
uniqueId: nanoid(),
updatedAt: item.updatedAt,
userFavorite: item.starred,
userRating: item.rating,
};
};
const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): AlbumArtist => {
const imageUrl =
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
return {
albumCount: item.albumCount,
backgroundImageUrl: null,
biography: item.biography,
biography: item.biography || null,
duration: null,
genres: item.genres,
id: item.id,
imageUrl: item.largeImageUrl,
isFavorite: item.starred,
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
name: item.name,
playCount: item.playCount,
rating: item.rating,
serverId: server.id,
serverType: ServerType.NAVIDROME,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
songCount: item.songCount,
userFavorite: item.starred,
userRating: item.rating,
};
};
@@ -582,17 +701,34 @@ const normalizePlaylist = (
id: item.id,
imagePlaceholderUrl,
imageUrl,
itemType: LibraryItem.PLAYLIST,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
public: item.public,
rules: item?.rules || null,
serverId: server.id,
serverType: ServerType.NAVIDROME,
size: item.size,
songCount: item.songCount,
userId: item.ownerId,
username: item.ownerName,
sync: item.sync,
};
};
const normalizeUser = (item: NDUser): User => {
return {
createdAt: item.createdAt,
email: item.email,
id: item.id,
isAdmin: item.isAdmin,
lastLoginAt: item.lastLoginAt,
name: item.userName,
updatedAt: item.updatedAt,
};
};
export const navidromeApi = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
@@ -606,6 +742,8 @@ export const navidromeApi = {
getPlaylistSongList,
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};
@@ -614,4 +752,5 @@ export const ndNormalize = {
albumArtist: normalizeAlbumArtist,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
};
+131 -6
View File
@@ -1,3 +1,5 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
@@ -8,6 +10,18 @@ export type NDAuthenticate = {
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 = {
id: string;
name: string;
@@ -101,19 +115,21 @@ export type NDAlbumArtist = {
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl: string;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAuthenticationResponse = NDAuthenticate;
@@ -214,8 +230,8 @@ export type NDAlbumListParams = {
export enum NDSongListSort {
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
ALBUM_ARTIST = 'albumArtist',
ALBUM_SONGS = 'discNumber, trackNumber',
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
ALBUM_SONGS = 'album, discNumber, trackNumber',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
@@ -227,9 +243,10 @@ export enum NDSongListSort {
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
YEAR = 'year, album, discNumber, trackNumber',
}
export type NDSongListParams = {
@@ -257,6 +274,26 @@ export type NDAlbumArtistListParams = {
} & NDPagination &
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 = {
comment?: string;
name: string;
@@ -339,3 +376,91 @@ export type NDPlaylistSongList = {
startIndex: 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',
}
+71 -2
View File
@@ -14,10 +14,14 @@ import type {
NDGenreList,
NDPlaylist,
NDSong,
NDUser,
} from '/@/renderer/api/navidrome.types';
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
import { ssNormalize } from '/@/renderer/api/subsonic.api';
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
import type {
Album,
AlbumArtist,
RawAlbumArtistDetailResponse,
RawAlbumArtistListResponse,
RawAlbumDetailResponse,
RawAlbumListResponse,
@@ -26,6 +30,8 @@ import type {
RawPlaylistDetailResponse,
RawPlaylistListResponse,
RawSongListResponse,
RawTopSongListResponse,
RawUserListResponse,
} from '/@/renderer/api/types';
import { ServerListItem } from '/@/renderer/types';
@@ -88,6 +94,26 @@ const songList = (data: RawSongListResponse | undefined, server: ServerListItem
};
};
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
let songs;
switch (server?.type) {
case 'jellyfin':
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
break;
case 'navidrome':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
case 'subsonic':
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
break;
}
return {
items: songs,
};
};
const musicFolderList = (
data: RawMusicFolderListResponse | undefined,
server: ServerListItem | null,
@@ -147,6 +173,25 @@ const genreList = (data: RawGenreListResponse | undefined, server: ServerListIte
return genres;
};
const albumArtistDetail = (
data: RawAlbumArtistDetailResponse | undefined,
server: ServerListItem | null,
): AlbumArtist | undefined => {
let albumArtist: AlbumArtist | undefined;
switch (server?.type) {
case 'jellyfin':
albumArtist = jfNormalize.albumArtist(data as JFAlbumArtist, server);
break;
case 'navidrome':
albumArtist = ndNormalize.albumArtist(data as NDAlbumArtist, server);
break;
case 'subsonic':
break;
}
return albumArtist;
};
const albumArtistList = (
data: RawAlbumArtistListResponse | undefined,
server: ServerListItem | null,
@@ -159,7 +204,9 @@ const albumArtistList = (
);
break;
case 'navidrome':
albumArtists = data?.items.map((item) => ndNormalize.albumArtist(item as NDAlbumArtist));
albumArtists = data?.items.map((item) =>
ndNormalize.albumArtist(item as NDAlbumArtist, server),
);
break;
case 'subsonic':
break;
@@ -211,7 +258,27 @@ const playlistDetail = (
return playlist;
};
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
let users;
switch (server?.type) {
case 'jellyfin':
break;
case 'navidrome':
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
break;
case 'subsonic':
break;
}
return {
items: users,
startIndex: data?.startIndex,
totalRecordCount: data?.totalRecordCount,
};
};
export const normalize = {
albumArtistDetail,
albumArtistList,
albumDetail,
albumList,
@@ -220,4 +287,6 @@ export const normalize = {
playlistDetail,
playlistList,
songList,
topSongList,
userList,
};
+15 -1
View File
@@ -7,11 +7,14 @@ import type {
PlaylistListQuery,
PlaylistDetailQuery,
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
} from './types';
export const queryKeys = {
albumArtists: {
detail: (serverId: string, query?: AlbumArtistListQuery) => {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const;
},
@@ -20,6 +23,10 @@ export const queryKeys = {
return [serverId, 'albumArtists', 'list'] 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: {
detail: (serverId: string, query?: AlbumDetailQuery) =>
@@ -79,4 +86,11 @@ export const queryKeys = {
},
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,
},
};
+250 -62
View File
@@ -1,6 +1,6 @@
import ky from 'ky';
import md5 from 'md5';
import { randomString } from '/@/renderer/utils';
import { parseSearchParams, randomString } from '/@/renderer/utils';
import type {
SSAlbumListResponse,
SSAlbumDetailResponse,
@@ -16,26 +16,43 @@ import type {
SSAlbumArtistDetail,
SSAlbumArtistDetailResponse,
SSFavoriteParams,
SSFavoriteResponse,
SSRatingParams,
SSRatingResponse,
SSAlbumArtistDetailParams,
SSAlbumArtistListParams,
SSTopSongListParams,
SSTopSongListResponse,
SSArtistInfoParams,
SSArtistInfoResponse,
SSArtistInfo,
SSSong,
SSTopSongList,
SSScrobbleParams,
} from '/@/renderer/api/subsonic.types';
import type {
import {
AlbumArtistDetailArgs,
AlbumArtistListArgs,
AlbumDetailArgs,
AlbumListArgs,
ArtistInfoArgs,
AuthenticationResponse,
FavoriteArgs,
FavoriteResponse,
GenreListArgs,
LibraryItem,
MusicFolderListArgs,
QueueSong,
RatingArgs,
RatingResponse,
RawScrobbleResponse,
ScrobbleArgs,
ServerListItem,
ServerType,
TopSongListArgs,
} from '/@/renderer/api/types';
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
import { nanoid } from 'nanoid/non-secure';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const getCoverArtUrl = (args: {
baseUrl: string;
@@ -50,7 +67,7 @@ const getCoverArtUrl = (args: {
}
return (
`${args.baseUrl}/getCoverArt.view` +
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
@@ -65,40 +82,44 @@ const api = ky.create({
async (_request, _options, response) => {
const data = await response.json();
if (data['subsonic-response'].status !== 'ok') {
toast.warn({ message: 'Issue from Subsonic API' });
// Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) {
toast.error({
message: data['subsonic-response'].error.message,
title: 'Issue from Subsonic API',
});
}
}
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
},
],
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 });
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const getDefaultParams = (server: ServerListItem | null) => {
if (!server) return {};
const authParams = server.credential.split(/&?\w=/gm);
const params: Record<string, string> = {
c: 'Feishin',
f: 'json',
u: server.username,
v: '1.13.0',
};
if (authParams?.length === 4) {
params.s = authParams[2];
params.t = authParams[3];
} else if (authParams?.length === 3) {
params.p = authParams[2];
}
return params;
};
const authenticate = async (
url: string,
body: {
@@ -129,10 +150,12 @@ const authenticate = async (
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
const { signal, server } = args;
const defaultParams = getDefaultParams(server);
const data = await api
.get('rest/getMusicFolders.view', {
prefixUrl: server?.url,
searchParams: defaultParams,
signal,
})
.json<SSMusicFolderListResponse>();
@@ -144,9 +167,11 @@ 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
@@ -162,9 +187,11 @@ export const getAlbumArtistDetail = async (
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
@@ -186,10 +213,12 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArt
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>();
@@ -199,11 +228,17 @@ const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
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: { id: query.id },
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSAlbumDetailResponse>();
@@ -214,12 +249,15 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> =>
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const normalizedParams = {};
const searchParams = {
...defaultParams,
};
const data = await api
.get('rest/getAlbumList2.view', {
prefixUrl: server?.url,
searchParams: normalizedParams,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSAlbumListResponse>();
@@ -233,65 +271,208 @@ const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
for (const id of query.id) {
const searchParams: SSFavoriteParams = {
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
id: query.type === LibraryItem.SONG ? id : undefined,
...defaultParams,
};
await api
.get('rest/star.view', {
await api.get('rest/star.view', {
prefixUrl: server?.url,
searchParams,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSFavoriteResponse>();
});
// .json<SSFavoriteResponse>();
}
return {
id: query.id,
type: query.type,
};
};
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSFavoriteParams = {
albumId: query.type === 'album' ? query.id : undefined,
artistId: query.type === 'albumArtist' ? query.id : undefined,
id: query.type === 'song' ? query.id : undefined,
};
for (const id of query.id) {
const searchParams: SSFavoriteParams = {
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
id: query.type === LibraryItem.SONG ? id : undefined,
...defaultParams,
};
await api
.get('rest/unstar.view', {
await api.get('rest/unstar.view', {
prefixUrl: server?.url,
searchParams,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSFavoriteResponse>();
});
// .json<SSFavoriteResponse>();
}
return {
id: query.id,
type: query.type,
};
};
const updateRating = async (args: RatingArgs) => {
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSRatingParams = {
id: query.id,
rating: query.rating,
const itemIds = query.item.map((item) => item.id);
for (const id of itemIds) {
const searchParams: SSRatingParams = {
id,
rating: query.rating,
...defaultParams,
};
await api.get('rest/setRating.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
});
}
return null;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSTopSongListParams = {
artist: query.artist,
count: query.limit,
...defaultParams,
};
const data = await api
.get('rest/setRating.view', {
.get('rest/getTopSongs.view', {
prefixUrl: server?.url,
searchParams: parseSearchParams(searchParams),
signal,
})
.json<SSTopSongListResponse>();
return {
items: data?.topSongs?.song,
startIndex: 0,
totalRecordCount: data?.topSongs?.song?.length || 0,
};
};
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSArtistInfoParams = {
count: query.limit,
id: query.artistId,
...defaultParams,
};
const data = await api
.get('rest/getArtistInfo2.view', {
prefixUrl: server?.url,
searchParams,
signal,
})
.json<SSRatingResponse>();
.json<SSArtistInfoResponse>();
return data;
return data.artistInfo2;
};
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
const { signal, server, query } = args;
const defaultParams = getDefaultParams(server);
const searchParams: SSScrobbleParams = {
id: query.id,
submission: query.submission,
...defaultParams,
};
await api.get('rest/scrobble.view', {
prefixUrl: server?.url,
searchParams,
signal,
});
return null;
};
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
const imageUrl =
getCoverArtUrl({
baseUrl: server.url,
coverArtId: item.coverArt,
credential: server.credential,
size: 300,
}) || null;
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
return {
album: item.album,
albumArtists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
albumId: item.albumId,
artistName: item.artist,
artists: [
{
id: item.artistId || '',
imageUrl: null,
name: item.artist,
},
],
bitRate: item.bitRate,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: item.contentType,
createdAt: item.created,
discNumber: item.discNumber || 1,
duration: item.duration,
genres: [
{
id: item.genre,
name: item.genre,
},
],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
itemType: LibraryItem.SONG,
lastPlayedAt: null,
name: item.title,
path: item.path,
playCount: item?.playCount || 0,
releaseDate: null,
releaseYear: item.year ? String(item.year) : null,
serverId: server.id,
serverType: ServerType.SUBSONIC,
size: item.size,
streamUrl,
trackNumber: item.track,
uniqueId: nanoid(),
updatedAt: '',
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const subsonicApi = {
@@ -302,8 +483,15 @@ export const subsonicApi = {
getAlbumArtistList,
getAlbumDetail,
getAlbumList,
getArtistInfo,
getCoverArtUrl,
getGenreList,
getMusicFolderList,
getTopSongList,
scrobble,
updateRating,
};
export const ssNormalize = {
song: normalizeSong,
};
+36
View File
@@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
@@ -75,6 +81,13 @@ export type SSArtistInfo = {
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
@@ -186,3 +199,26 @@ export type SSRatingParams = {
export type SSRating = 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;
};
+189 -25
View File
@@ -33,6 +33,8 @@ import {
NDPlaylistListSort,
NDPlaylistDetail,
NDSongListSort,
NDUserList,
NDUserListSort,
} from '/@/renderer/api/navidrome.types';
import {
SSAlbumList,
@@ -41,13 +43,42 @@ import {
SSAlbumArtistDetail,
SSMusicFolderList,
SSGenreList,
SSTopSongList,
} from '/@/renderer/api/subsonic.types';
export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
PLAYLIST = 'playlist',
SONG = 'song',
}
export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong;
export type AnyLibraryItems =
| Album[]
| AlbumArtist[]
| Artist[]
| Playlist[]
| Song[]
| QueueSong[];
export enum SortOrder {
ASC = 'ASC',
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 = {
credential: string;
id: string;
@@ -143,19 +174,21 @@ export type Album = {
imagePlaceholderUrl: string | null;
imageUrl: string | null;
isCompilation: boolean | null;
isFavorite: boolean;
itemType: LibraryItem.ALBUM;
lastPlayedAt: string | null;
name: string;
playCount: number | null;
rating: number | null;
releaseDate: string | null;
releaseYear: number | null;
serverId: string;
serverType: ServerType;
size: number | null;
songCount: number | null;
songs?: Song[];
uniqueId: string;
updatedAt: string;
userFavorite: boolean;
userRating: number | null;
} & { songs?: Song[] };
export type Song = {
@@ -167,6 +200,7 @@ export type Song = {
bitRate: number;
bpm: number | null;
channels: number | null;
comment: string | null;
compilation: boolean | null;
container: string | null;
createdAt: string;
@@ -176,21 +210,23 @@ export type Song = {
id: string;
imagePlaceholderUrl: string | null;
imageUrl: string | null;
isFavorite: boolean;
itemType: LibraryItem.SONG;
lastPlayedAt: string | null;
name: string;
note: string | null;
path: string | null;
playCount: number;
playlistItemId?: string;
releaseDate: string | null;
releaseYear: string | null;
serverId: string;
serverType: ServerType;
size: number;
streamUrl: string;
trackNumber: number;
type: ServerType;
uniqueId: string;
updatedAt: string;
userFavorite: boolean;
userRating: number | null;
};
export type AlbumArtist = {
@@ -201,12 +237,16 @@ export type AlbumArtist = {
genres: Genre[];
id: string;
imageUrl: string | null;
isFavorite: boolean;
itemType: LibraryItem.ALBUM_ARTIST;
lastPlayedAt: string | null;
name: string;
playCount: number | null;
rating: number | null;
serverId: string;
serverType: ServerType;
similarArtists: RelatedArtist[] | null;
songCount: number | null;
userFavorite: boolean;
userRating: number | null;
};
export type RelatedAlbumArtist = {
@@ -218,14 +258,18 @@ export type Artist = {
biography: string | null;
createdAt: string;
id: string;
itemType: LibraryItem.ARTIST;
name: string;
remoteCreatedAt: string | null;
serverFolderId: string;
serverId: string;
serverType: ServerType;
updatedAt: string;
};
export type RelatedArtist = {
id: string;
imageUrl: string | null;
name: string;
};
@@ -241,13 +285,17 @@ export type Playlist = {
id: string;
imagePlaceholderUrl: string | null;
imageUrl: string | null;
itemType: LibraryItem.PLAYLIST;
name: string;
owner: string | null;
ownerId: string | null;
public: boolean | null;
rules?: Record<string, any> | null;
serverId: string;
serverType: ServerType;
size: number | null;
songCount: number | null;
userId: string | null;
username: string | null;
sync?: boolean | null;
};
export type GenresResponse = Genre[];
@@ -257,6 +305,7 @@ export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = {
_serverId?: string;
server: ServerListItem | null;
signal?: AbortSignal;
};
@@ -294,9 +343,11 @@ export enum AlbumListSort {
}
export type AlbumListQuery = {
artistIds?: string[];
jfParams?: {
albumArtistIds?: string;
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
@@ -424,6 +475,8 @@ export type SongListQuery = {
albumIds?: string[];
artistIds?: string[];
jfParams?: {
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
@@ -496,7 +549,7 @@ export const songListSortMap: SongListSortMap = {
playCount: NDSongListSort.PLAY_COUNT,
random: undefined,
rating: NDSongListSort.RATING,
recentlyAdded: NDSongListSort.PLAY_DATE,
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
recentlyPlayed: NDSongListSort.PLAY_DATE,
releaseDate: undefined,
year: NDSongListSort.YEAR,
@@ -719,34 +772,71 @@ export const artistListSortMap: ArtistListSortMap = {
// Favorite
export type RawFavoriteResponse = FavoriteResponse | undefined;
export type FavoriteResponse = { id: string };
export type FavoriteResponse = { id: string[]; type: LibraryItem };
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' };
export type FavoriteQuery = {
id: string[];
type: LibraryItem;
};
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
// Rating
export type RawRatingResponse = null | undefined;
export type RawRatingResponse = RatingResponse | undefined;
export type RatingResponse = null;
export type RatingQuery = { id: string; rating: number };
export type RatingQuery = {
item: AnyLibraryItems;
rating: number;
};
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
// Add to playlist
export type RawAddToPlaylistResponse = null | undefined;
export type AddToPlaylistQuery = {
id: string;
};
export type AddToPlaylistBody = {
songId: string[];
};
export type AddToPlaylistArgs = {
body: AddToPlaylistBody;
query: AddToPlaylistQuery;
} & BaseEndpointArgs;
// Remove from playlist
export type RawRemoveFromPlaylistResponse = null | undefined;
export type RemoveFromPlaylistQuery = {
id: string;
songId: string[];
};
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
// Create Playlist
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
export type CreatePlaylistResponse = { id: string; name: string };
export type CreatePlaylistQuery = {
export type CreatePlaylistBody = {
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any>;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
export type CreatePlaylistArgs = { body: CreatePlaylistBody } & BaseEndpointArgs;
// Update Playlist
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
@@ -761,8 +851,13 @@ export type UpdatePlaylistBody = {
comment?: string;
genres?: Genre[];
name: string;
public?: boolean;
rules?: Record<string, any>;
ndParams?: {
owner?: string;
ownerId?: string;
public?: boolean;
rules?: Record<string, any>;
sync?: boolean;
};
};
export type UpdatePlaylistArgs = {
@@ -797,6 +892,7 @@ export type PlaylistListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
smart?: boolean;
};
searchTerm?: string;
sortBy: PlaylistListSort;
@@ -872,11 +968,79 @@ export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
export type MusicFolderListArgs = BaseEndpointArgs;
// Create Favorite
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined;
// User list
// Playlist List
export type RawUserListResponse = NDUserList | undefined;
export type CreateFavoriteResponse = { id: string };
export type UserListResponse = BasePaginatedResponse<User[]>;
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
export enum UserListSort {
NAME = 'name',
}
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;
export type UserListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
};
searchTerm?: string;
sortBy: UserListSort;
sortOrder: SortOrder;
startIndex: number;
};
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 RawTopSongListResponse = SSTopSongList | JFSongList | undefined;
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 RawScrobbleResponse = null | undefined;
export type ScrobbleArgs = {
query: ScrobbleQuery;
} & BaseEndpointArgs;
export type ScrobbleQuery = {
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
id: string;
position?: number;
submission: boolean;
};
+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 { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal } from './components';
import { useTheme } from './hooks';
@@ -15,11 +14,17 @@ import '@ag-grid-community/styles/ag-grid.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
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]);
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export const App = () => {
const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent);
@@ -31,20 +36,41 @@ export const App = () => {
root.style.setProperty('--content-font-family', 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 (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
breakpoints: {
lg: 1200,
md: 1000,
sm: 800,
xl: 1400,
xs: 500,
},
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',
dir: 'ltr',
focusRing: 'auto',
@@ -60,53 +86,51 @@ export const App = () => {
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: 16,
md: 14,
sm: 12,
xl: 18,
xs: 10,
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
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: {},
spacing: {
lg: 12,
md: 8,
sm: 4,
xl: 16,
xs: 2,
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<NotificationsProvider
autoClose={1500}
position="bottom-center"
style={{
marginBottom: '85px',
opacity: '.8',
userSelect: 'none',
width: '300px',
}}
transitionDuration={200}
>
<ModalsProvider
modalProps={{
centered: true,
exitTransitionDuration: 300,
overflow: 'inside',
overlayBlur: 0,
overlayOpacity: 0.8,
<ModalsProvider
modalProps={{
centered: true,
transitionProps: {
duration: 300,
exitDuration: 300,
transition: 'slide-down',
transitionDuration: 300,
}}
modals={{ base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
</ModalsProvider>
</NotificationsProvider>
},
}}
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
>
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
<ContextMenuProvider>
<AppRouter />
</ContextMenuProvider>
</PlayQueueHandlerContext.Provider>
</ModalsProvider>
</MantineProvider>
);
};
@@ -52,7 +52,7 @@ export const AudioPlayer = forwardRef(
const player1Ref = useRef<any>(null);
const player2Ref = useRef<any>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.player.audioDeviceId);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
useImperativeHandle(ref, () => ({
get player1() {
+2 -1
View File
@@ -5,12 +5,13 @@ import styled from 'styled-components';
export type BadgeProps = MantineBadgeProps;
const StyledBadge = styled(MantineBadge)<BadgeProps>`
border-radius: var(--badge-radius);
.mantine-Badge-root {
color: var(--badge-fg);
}
.mantine-Badge-inner {
padding: 0 0.5rem;
color: var(--badge-fg);
}
`;
+14 -123
View File
@@ -20,145 +20,36 @@ interface StyledButtonProps extends ButtonProps {
}
const StyledButton = styled(MantineButton)<StyledButtonProps>`
color: ${(props) => {
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 '';
}
}};
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;
color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
border: ${(props) => `var(--btn-${props.variant}-border)`};
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
svg {
transition: fill 0.2s ease-in-out;
fill: ${(props) => {
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 '';
}
}};
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
}
&:disabled {
color: ${(props) => {
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 '';
}
}};
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 '';
}
}};
color: ${(props) => `var(--btn-${props.variant}-fg)`};
background: ${(props) => `var(--btn-${props.variant}-bg)`};
opacity: 0.6;
}
&:hover {
color: ${(props) => {
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 '';
}
}};
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 '';
}
}};
&:not([data-disabled])&:hover {
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
svg {
fill: ${(props) => {
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 '';
}
}};
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
}
}
&:focus-visible {
color: ${(props) => {
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 '';
}
}};
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 '';
}
}};
&:not([data-disabled])&:focus-visible {
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
}
&:active {
+9 -104
View File
@@ -1,15 +1,14 @@
import React, { useCallback } from 'react';
import { useCallback } from 'react';
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components';
import { Text } from '/@/renderer/components/text';
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import { Skeleton } from '/@/renderer/components/skeleton';
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<{
link?: boolean;
@@ -103,7 +102,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
interface BaseGridCardProps {
controls: {
cardRows: CardRow<Album>[];
cardRows: CardRow<Album | Artist | AlbumArtist>[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
@@ -179,104 +178,10 @@ export const AlbumCard = ({
</ControlsContainer>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow<Album>, index: number) => {
if (row.arrayProperty && row.route) {
return (
<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>
);
})}
<CardRows
data={data}
rows={cardRows}
/>
</DetailSection>
</StyledCard>
</CardWrapper>
+28 -51
View File
@@ -5,10 +5,15 @@ import { Group } from '@mantine/core';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
import type { PlayQueueAddOptions } 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'>;
@@ -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 = ({
itemData,
itemType,
@@ -122,7 +112,7 @@ export const CardControls = ({
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
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 (
<GridCardControlsContainer>
<BottomControls>
@@ -160,39 +155,21 @@ export const CardControls = ({
)}
</FavoriteWrapper>
</SecondaryButton>
<DropdownMenu
withinPortal
position="bottom-start"
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<DropdownMenu.Target>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
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>
<RiMore2Fill
color="white"
size={20}
/>
</SecondaryButton>
</Group>
</BottomControls>
</GridCardControlsContainer>
+7 -6
View File
@@ -39,6 +39,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
{itemIndex > 0 && (
<Text
$noSelect
$secondary
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
@@ -59,7 +60,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
row.route!.slugs?.reduce((acc, slug) => {
return {
...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',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
@@ -142,7 +143,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
arrayProperty: 'name',
property: 'artists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
@@ -166,7 +167,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
property: 'playCount',
},
rating: {
property: 'rating',
property: 'userRating',
},
releaseDate: {
property: 'releaseDate',
@@ -195,7 +196,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
name: {
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
@@ -203,7 +204,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
property: 'playCount',
},
rating: {
property: 'rating',
property: 'userRating',
},
songCount: {
property: 'songCount',
+87 -18
View File
@@ -1,8 +1,7 @@
import { forwardRef, ReactNode, Ref } from 'react';
import { Portal } from '@mantine/core';
import { motion } from 'framer-motion';
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
import { motion, Variants } from 'framer-motion';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
interface ContextMenuProps {
children: ReactNode;
@@ -19,40 +18,110 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
z-index: 1000;
min-width: ${({ minWidth }) => minWidth}px;
max-width: ${({ maxWidth }) => maxWidth}px;
padding: 0.5rem;
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%);
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)`
padding: 0.5rem;
export const StyledContextMenuButton = styled(UnstyledButton)`
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);
border: none;
cursor: default;
& .mantine-Button-inner {
justify-content: flex-start;
}
&:hover {
background: var(--dropdown-menu-bg-hover);
}
&:disabled {
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(
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
return (
<Portal>
<ContextMenuContainer
ref={ref}
maxWidth={maxWidth}
minWidth={minWidth}
xPos={xPos}
yPos={yPos}
>
{children}
</ContextMenuContainer>
</Portal>
<ContextMenuContainer
ref={ref}
animate="open"
initial="closed"
maxWidth={maxWidth}
minWidth={minWidth}
variants={variants}
xPos={xPos}
yPos={yPos}
>
{children}
</ContextMenuContainer>
);
},
);
@@ -37,7 +37,6 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
return (
<StyledDatePicker
withinPortal
{...props}
sx={{ maxWidth, width }}
/>
+43 -23
View File
@@ -6,12 +6,12 @@ import type {
MenuDropdownProps as MantineMenuDropdownProps,
} from '@mantine/core';
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
import { RiArrowLeftSFill } from 'react-icons/ri';
import styled from 'styled-components';
type MenuProps = MantineMenuProps;
type MenuLabelProps = MantineMenuLabelProps;
interface MenuItemProps extends MantineMenuItemProps {
$danger?: boolean;
$isActive?: boolean;
children: React.ReactNode;
}
@@ -21,17 +21,32 @@ type MenuDropdownProps = MantineMenuDropdownProps;
const StyledMenu = styled(MantineMenu)<MenuProps>``;
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
padding: 0.5rem;
font-family: var(--content-font-family);
`;
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
padding: 0.8rem;
font-size: 0.9em;
position: relative;
padding: var(--dropdown-menu-item-padding);
font-size: var(--dropdown-menu-item-font-size);
font-family: var(--content-font-family);
background-color: ${({ $isActive }) => {
if (!$isActive) return undefined;
return 'var(--dropdown-menu-bg-hover)';
}};
${(props) =>
props.$isActive &&
`
&::before {
content: ''; // ::before and ::after both require content
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--dropdown-menu-bg-hover);
opacity: 0.5;
z-index: -1;
}
`}
&:disabled {
opacity: 0.6;
@@ -41,44 +56,48 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
background-color: var(--dropdown-menu-bg-hover);
}
& .mantine-Menu-itemIcon {
margin-right: 0.5rem;
}
& .mantine-Menu-itemLabel {
color: var(--dropdown-menu-fg);
font-weight: 500;
font-size: 1em;
margin-right: 2rem;
margin-left: 1rem;
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
}
& .mantine-Menu-itemRightSection {
display: flex;
margin-left: 2rem !important;
}
cursor: default;
`;
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
margin: 0;
padding: 0;
background: var(--dropdown-menu-bg);
border: var(--dropdown-menu-border);
border-radius: var(--dropdown-menu-border-radius);
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)`
margin: 0.3rem 0;
margin: 0;
padding: 0;
`;
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
return (
<StyledMenu
withinPortal
radius="sm"
styles={{
dropdown: {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
transition="scale-y"
{...props}
>
{children}
@@ -90,11 +109,12 @@ const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
};
const pMenuItem = ({ $isActive, children, ...props }: MenuItemProps) => {
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
return (
<StyledMenuItem
$danger={$danger}
$isActive={$isActive}
rightSection={$isActive && <RiArrowLeftSFill size={20} />}
// rightSection={$isActive && <RiArrowLeftSFill size={20} />}
{...props}
>
{children}
@@ -27,8 +27,7 @@ const Grid = styled.div`
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 225px 1fr;
gap: 0.5rem;
grid-template-columns: 200px 1fr;
width: 100%;
max-width: 100%;
height: 100%;
@@ -152,16 +151,27 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
/>
</ImageColumn>
<InfoColumn>
<Stack sx={{ width: '100%' }}>
<Stack
spacing="md"
sx={{ width: '100%' }}
>
<TitleWrapper>
<TextTitle fw="bold">{currentItem?.name}</TextTitle>
<TextTitle
lh="4rem"
lineClamp={2}
order={1}
sx={{ fontSize: '4rem' }}
weight={900}
>
{currentItem?.name}
</TextTitle>
</TitleWrapper>
<TitleWrapper>
{currentItem?.albumArtists.map((artist) => (
<TextTitle
key={`carousel-artist-${artist.id}`}
fw="600"
order={3}
order={2}
weight={600}
>
{artist.name}
</TextTitle>
@@ -169,10 +179,15 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</TitleWrapper>
<Group>
{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>{currentItem?.songCount} tracks</Badge>
<Badge size="lg">{currentItem?.releaseYear}</Badge>
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
</Group>
</Stack>
</InfoColumn>
@@ -187,25 +202,25 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
</AnimatePresence>
<Group
spacing="xs"
sx={{ bottom: 0, position: 'absolute', right: 0, zIndex: 20 }}
sx={{ bottom: '1rem', position: 'absolute', right: 0, zIndex: 20 }}
>
<Button
disabled={itemIndex === 0}
px="lg"
radius={100}
variant="subtle"
size="md"
variant="default"
onClick={handlePrevious}
>
<RiArrowLeftSLine size={15} />
<RiArrowLeftSLine size="2rem" />
</Button>
<Button
disabled={itemIndex === (data?.length || 1) - 1}
px="lg"
radius={100}
variant="subtle"
size="md"
variant="default"
onClick={handleNext}
>
<RiArrowRightSLine size={15} />
<RiArrowRightSLine size="2rem" />
</Button>
</Group>
</Wrapper>
+35 -23
View File
@@ -6,16 +6,19 @@ import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { AppRoute } from '/@/renderer/router/routes';
import type { CardRow } from '/@/renderer/types';
import { LibraryItem, Play } from '/@/renderer/types';
import { Play } from '/@/renderer/types';
import styled from 'styled-components';
import { AlbumCard } from '/@/renderer/components/card';
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
import { LibraryItem } from '/@/renderer/api/types';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
interface GridCarouselProps {
cardRows: CardRow<any>[];
children: React.ReactElement;
containerWidth: number;
data: any[] | undefined;
itemType: LibraryItem;
loading?: boolean;
pagination?: {
handleNextPage?: () => void;
@@ -77,9 +80,11 @@ const variants: Variants = {
};
const Carousel = ({ data, cardRows }: any) => {
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } =
const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
useContext(GridCarouselContext);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
return (
@@ -104,9 +109,9 @@ const Carousel = ({ data, cardRows }: any) => {
key={`card-${uniqueId}-${index}`}
controls={{
cardRows,
itemType: LibraryItem.ALBUM,
playButtonBehavior: Play.NOW,
route: {
itemType: itemType || LibraryItem.ALBUM,
playButtonBehavior: playButtonBehavior || Play.NOW,
route: cardRows[0]?.route || {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
@@ -130,6 +135,7 @@ export const GridCarousel = ({
children,
containerWidth,
uniqueId,
itemType,
}: GridCarouselProps) => {
const [direction, setDirection] = useState(0);
@@ -147,12 +153,13 @@ export const GridCarousel = ({
direction,
gridHeight,
imageSize,
itemType,
loading,
pagination,
setDirection,
uniqueId,
}),
[cardRows, data, direction, gridHeight, imageSize, loading, pagination, uniqueId],
[cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
);
return (
@@ -176,6 +183,7 @@ interface TitleProps {
const Title = ({ children }: TitleProps) => {
const { pagination, setDirection } = useContext(GridCarouselContext);
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
const handleNextPage = useCallback(() => {
setDirection(1);
@@ -190,23 +198,27 @@ const Title = ({ children }: TitleProps) => {
return (
<Group position="apart">
{children}
<Group>
<Button
compact
disabled={!pagination?.hasPreviousPage}
variant="default"
onClick={handlePreviousPage}
>
<RiArrowLeftSLine size={15} />
</Button>
<Button
compact
variant="default"
onClick={handleNextPage}
>
<RiArrowRightSLine size={15} />
</Button>
</Group>
{showPaginationButtons && (
<Group spacing="sm">
<Button
compact
disabled={!pagination?.hasPreviousPage}
size="md"
variant="default"
onClick={handlePreviousPage}
>
<RiArrowLeftSLine size={15} />
</Button>
<Button
compact
size="md"
variant="default"
onClick={handleNextPage}
>
<RiArrowRightSLine size={15} />
</Button>
</Group>
)}
</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
View File
@@ -31,3 +31,7 @@ export * from './virtual-grid';
export * from './virtual-table';
export * from './motion';
export * from './context-menu';
export * from './query-builder';
export * from './rating';
export * from './hover-card';
export * from './option';
+24
View File
@@ -83,6 +83,10 @@ const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
@@ -130,6 +134,10 @@ const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
@@ -159,6 +167,10 @@ const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
@@ -188,6 +200,10 @@ const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
@@ -217,6 +233,10 @@ const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
transition: width 0.3s ease-in-out;
`;
@@ -242,6 +262,10 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
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) => {
return (
<MantineModal
overlayBlur={2}
overlayOpacity={0.2}
{...rest}
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 { AnimatePresence, motion, Variants } from 'framer-motion';
import { useRef } from 'react';
import styled from 'styled-components';
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
const Container = styled(motion(Flex))<{
height?: string;
@@ -11,18 +13,23 @@ const Container = styled(motion(Flex))<{
position: ${(props) => props.position || 'relative'};
z-index: 2000;
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;
z-index: 15;
width: 100%;
height: 100%;
margin-right: ${(props) => props.$padRight && '170px'};
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
-webkit-app-region: drag;
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
button {
-webkit-app-region: no-drag;
@@ -51,7 +58,7 @@ const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
height: 100%;
background: ${(props) =>
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%))'};
`;
@@ -65,6 +72,8 @@ export interface PageHeaderProps
}
const TitleWrapper = styled(motion.div)`
position: absolute;
display: flex;
width: 100%;
height: 100%;
`;
@@ -85,6 +94,7 @@ export const PageHeader = ({
}: PageHeaderProps) => {
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
const { windowBarStyle } = useWindowSettings();
const theme = useTheme();
return (
@@ -95,6 +105,7 @@ export const PageHeader = ({
{...props}
>
<Header
$isDraggable={windowBarStyle === Platform.WEB}
$isHidden={isHidden}
$padRight={padRight}
>
@@ -46,7 +46,6 @@ export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
<StyledPagination
$hideDividers={$hideDividers}
radius="xl"
size="md"
{...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%))',
},
}}
transition="scale-y"
transitionProps={{ transition: 'fade' }}
{...props}
>
{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 styled from 'styled-components';
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
interface ScrollAreaProps extends MantineScrollAreaProps {
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%;
overflow-y: overlay;
overflow-y: overlay !important;
&::-webkit-scrollbar-track {
margin-top: 35px;
margin-top: ${(props) =>
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
}
&::-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 {
children: React.ReactNode;
debugScrollPosition?: boolean;
noHeader?: boolean;
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
scrollBarOffset?: string;
scrollHideDelay?: number;
style?: React.CSSProperties;
}
export const NativeScrollArea = forwardRef(
(
{ children, pageHeaderProps, debugScrollPosition, ...props }: NativeScrollAreaProps,
{
children,
pageHeaderProps,
debugScrollPosition,
scrollBarOffset,
scrollHideDelay,
noHeader,
...props
}: NativeScrollAreaProps,
ref: Ref<HTMLDivElement>,
) => {
const { windowBarStyle } = useWindowSettings();
const [hideScrollbar, setHideScrollbar] = useState(false);
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 mergedRef = useMergedRef(ref, containerRef);
@@ -103,15 +123,19 @@ export const NativeScrollArea = forwardRef(
return (
<>
<PageHeader
isHidden={hideHeader}
position="absolute"
style={{ opacity: scrollYProgress as any }}
{...pageHeaderProps}
/>
{!noHeader && (
<PageHeader
isHidden={hideHeader}
position="absolute"
style={{ opacity: scrollYProgress as any }}
{...pageHeaderProps}
/>
)}
<StyledNativeScrollArea
ref={mergedRef}
className={hideScrollbar ? 'hide-scrollbar' : undefined}
scrollBarOffset={scrollBarOffset}
windowBarStyle={windowBarStyle}
onMouseEnter={() => {
setHideScrollbar(false);
clear();
@@ -43,9 +43,10 @@ export const SearchInput = ({
<TextInput
ref={mergedRef}
{...props}
icon={showIcon && <RiSearchLine size={15} />}
icon={showIcon && <RiSearchLine />}
size="md"
styles={{
icon: { svg: { fill: 'var(--btn-default-fg)' } },
icon: { svg: { fill: 'var(--titlebar-fg)' } },
input: {
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
border: 'none !important',
@@ -53,7 +54,7 @@ export const SearchInput = ({
padding: isOpened ? '10px' : 0,
},
}}
width={isOpened ? openedWidth || 150 : initialWidth || 50}
width={isOpened ? openedWidth || 150 : initialWidth || 35}
onChange={onChange}
onKeyDown={handleEscape}
/>
@@ -17,6 +17,10 @@ const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedC
opacity: 0.6;
}
& [data-disabled='true'] {
opacity: 0.6;
}
& .mantine-SegmentedControl-active {
color: var(--input-active-fg);
background-color: var(--input-active-bg);
+5 -7
View File
@@ -20,7 +20,7 @@ const StyledSelect = styled(MantineSelect)`
background: var(--input-bg);
}
& .mantine-Select-disabled {
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
@@ -64,8 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
},
}}
sx={{ maxWidth, width }}
transition="pop"
transitionDuration={100}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
@@ -76,8 +75,8 @@ const StyledMultiSelect = styled(MantineMultiSelect)`
background: var(--input-select-bg);
}
& .mantine-MultiSelect-disabled {
background: var(--input-select-bg);
& [data-disabled='true'] {
background: var(--input-bg);
opacity: 0.6;
}
@@ -126,8 +125,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
},
}}
sx={{ maxWidth, width }}
transition="pop"
transitionDuration={100}
transitionProps={{ duration: 100, transition: 'fade' }}
{...props}
/>
);
+4
View File
@@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
background-color: var(--slider-track-bg);
}
& .mantine-Slider-bar {
background-color: var(--primary-color);
}
& .mantine-Slider-thumb {
width: 1rem;
height: 1rem;
+19 -8
View File
@@ -1,5 +1,5 @@
import type { TabsProps as MantineTabsProps } from '@mantine/core';
import { Tabs as MantineTabs } from '@mantine/core';
import { Suspense } from 'react';
import { TabsPanelProps, TabsProps as MantineTabsProps, Tabs as MantineTabs } from '@mantine/core';
import styled from 'styled-components';
type TabsProps = MantineTabsProps;
@@ -12,16 +12,20 @@ const StyledTabs = styled(MantineTabs)`
}
&.mantine-Tabs-tab {
padding: 0.5rem 1rem;
font-weight: 600;
font-size: 1rem;
background-color: var(--main-bg);
}
& .mantine-Tabs-panel {
padding: 0 1rem;
padding: 1.5rem 0.5rem;
}
button {
padding: 1rem;
color: var(--btn-subtle-fg);
border-radius: 0;
&:hover {
color: var(--btn-subtle-fg-hover);
@@ -32,13 +36,12 @@ const StyledTabs = styled(MantineTabs)`
}
button[data-active] {
color: var(--btn-primary-fg);
background: var(--primary-color);
color: var(--btn-subtle-fg);
background: none;
border-color: var(--primary-color);
&:hover {
background: var(--btn-primary-bg-hover);
border-color: var(--primary-color);
background: none;
}
}
`;
@@ -47,6 +50,14 @@ export const Tabs = ({ children, ...props }: TabsProps) => {
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.Panel = StyledTabs.Panel;
Tabs.Panel = Panel;
Tabs.Tab = StyledTabs.Tab;
+1 -1
View File
@@ -10,7 +10,7 @@ interface TextTitleProps extends MantineTextTitleDivProps {
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children: ReactNode;
children?: ReactNode;
overflow?: 'hidden' | 'visible';
to?: string;
weight?: number;
+2 -2
View File
@@ -11,7 +11,7 @@ interface TextProps extends MantineTextDivProps {
$link?: boolean;
$noSelect?: boolean;
$secondary?: boolean;
children: ReactNode;
children?: ReactNode;
font?: Font;
overflow?: 'hidden' | 'visible';
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 (
<StyledText
$noSelect={$noSelect}
+10 -5
View File
@@ -30,16 +30,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
? 'Error'
: 'Info';
const defaultDuration = type === 'error' ? 3500 : 2000;
const defaultDuration = type === 'error' ? 4000 : 2000;
return showNotification({
autoClose: defaultDuration,
disallowClose: true,
styles: () => ({
closeButton: {},
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '.9em',
fontSize: '1rem',
},
loader: {
margin: '1rem',
@@ -47,10 +50,12 @@ const showToast = ({ type, ...props }: NotificationProps) => {
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1em',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
+4 -2
View File
@@ -27,6 +27,10 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
maxWidth: '250px',
},
}}
transitionProps={{
duration: 250,
transition: 'fade',
}}
{...rest}
>
{children}
@@ -37,8 +41,6 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
Tooltip.defaultProps = {
openDelay: 0,
position: 'top',
transition: 'fade',
transitionDuration: 250,
withArrow: true,
withinPortal: true,
};
@@ -1,38 +1,58 @@
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath, useNavigate } from 'react-router';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import { generatePath, useNavigate } from 'react-router-dom';
import { ListChildComponentProps } from 'react-window';
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 { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card';
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
link?: boolean;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
padding: 12px 12px 0;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
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);
border-radius: var(--card-default-radius);
cursor: ${({ link }) => link && 'pointer'};
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
cursor: pointer;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
&: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;
}
@@ -41,26 +61,16 @@ const CardWrapper = styled.div<{
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = 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 }>`
const ImageContainer = styled.div`
position: relative;
width: ${({ size }) => size && `${size - 24}px`};
height: ${({ size }) => size && `${size - 24}px`};
display: flex;
align-items: center;
height: 100%;
aspect-ratio: 1/1;
overflow: hidden;
background: var(--placeholder-bg);
border-radius: var(--card-default-radius);
&::before {
@@ -78,151 +88,80 @@ const ImageSection = styled.div<{ size?: number }>`
}
`;
const Image = styled(SimpleImg)`
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;
const Image = styled.img`
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
max-width: 100%;
height: auto;
object-fit: contain;
border: 0;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
const DetailContainer = styled.div`
margin-top: 0.5rem;
`;
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 = ({
listChildProps,
data,
columnIndex,
controls,
sizes,
isHidden,
}: BaseGridCardProps) => {
const navigate = useNavigate();
const { index } = listChildProps;
const { itemGap, itemHeight, itemWidth } = sizes;
const { itemType, cardRows, route, handlePlayQueueAdd } = controls;
const cardSize = itemWidth - 24;
if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
return (
<CardWrapper
key={`card-${columnIndex}-${index}`}
link
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
onClick={() =>
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
)
}
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
onClick={() => navigate(path)}
>
<StyledCard>
<ImageSection size={itemWidth}>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={cardSize}
imgStyle={{ objectFit: 'cover' }}
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
width={cardSize}
/>
) : (
<Center
sx={{
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>
<InnerCardContainer>
<ImageContainer>
<Image
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
src={data?.imageUrl}
/>
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
<DetailContainer>
<CardRows
data={data}
rows={cardRows}
rows={controls.cardRows}
/>
</DetailSection>
</StyledCard>
</CardWrapper>
</DetailContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
}
return (
<CardWrapper
key={`card-${columnIndex}-${index}`}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth + 12}
<DefaultCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
$isHidden={isHidden}
>
<StyledCard>
<Skeleton
visible
radius="sm"
>
<ImageSection size={itemWidth} />
</Skeleton>
<DetailSection>
{cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
<Skeleton
key={`row-${row.property}-${columnIndex}`}
height={20}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
/>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
<InnerCardContainer>
<ImageContainer>
<Skeleton
visible
radius="sm"
/>
</ImageContainer>
</InnerCardContainer>
</DefaultCardContainer>
);
};
@@ -1,18 +1,23 @@
import type { MouseEvent } from 'react';
import React from 'react';
import type { UnstyledButtonProps } from '@mantine/core';
import { Group } from '@mantine/core';
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import styled from 'styled-components';
import { _Button } from '/@/renderer/components/button';
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
import type { PlayQueueAddOptions } 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'>;
const PlayButton = styled.button<PlayButtonType>`
position: absolute;
display: flex;
align-items: center;
justify-content: center;
@@ -23,7 +28,7 @@ const PlayButton = styled.button<PlayButtonType>`
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
transition: scale 0.1s ease-in-out;
&:hover {
opacity: 1;
@@ -58,6 +63,8 @@ const SecondaryButton = styled(_Button)`
`;
const GridCardControlsContainer = styled.div`
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
@@ -71,24 +78,13 @@ const ControlsRow = styled.div`
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)`
position: absolute;
bottom: 0;
display: flex;
gap: 0.5rem;
align-items: flex-end;
justify-content: space-between;
justify-content: flex-end;
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 = ({
itemData,
itemType,
handlePlayQueueAdd,
handleFavorite,
}: {
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
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 (
<GridCardControlsContainer>
{/* <TopControls /> */}
{/* <CenterControls /> */}
<GridCardControlsContainer className="card-controls">
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<BottomControls>
<PlayButton onClick={handlePlay}>
<RiPlayFill size={25} />
</PlayButton>
<Group spacing="xs">
<SecondaryButton
disabled
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.isFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<DropdownMenu
withinPortal
position="bottom-start"
>
<DropdownMenu.Target>
<SecondaryButton
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
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>
<SecondaryButton
p={5}
variant="subtle"
onClick={handleFavorites}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.userFavorite ? (
<RiHeartFill size={20} />
) : (
<RiHeartLine
color="white"
size={20}
/>
)}
</FavoriteWrapper>
</SecondaryButton>
<SecondaryButton
p={5}
variant="subtle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, [itemData]);
}}
>
<RiMoreFill
color="white"
size={20}
/>
</SecondaryButton>
</BottomControls>
</GridCardControlsContainer>
);
@@ -3,21 +3,18 @@ import type { ListChildComponentProps } from 'react-window';
import { areEqual } from 'react-window';
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
import type { GridCardData } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types';
import { GridCardData, ListDisplayType } from '/@/renderer/types';
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
const {
itemHeight,
itemWidth,
columnCount,
itemGap,
itemCount,
cardRows,
itemData,
itemType,
playButtonBehavior,
handlePlayQueueAdd,
handleFavorite,
route,
display,
} = data as GridCardData;
@@ -26,39 +23,41 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
const startIndex = index * columnCount;
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;
for (let i = startIndex; i <= stopIndex; i += 1) {
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
cards.push(
<View
key={`card-${i}-${index}`}
columnIndex={i}
controls={{
cardRows,
handleFavorite,
handlePlayQueueAdd,
itemType,
playButtonBehavior,
route,
}}
data={itemData[i]}
isHidden={i > stopIndex}
listChildProps={{ index }}
sizes={{ itemGap, itemHeight, itemWidth }}
/>,
);
}
return (
<>
<div
style={{
...style,
alignItems: 'center',
display: 'flex',
justifyContent: 'start',
}}
>
{cards}
</div>
</>
<div
style={{
...style,
display: 'flex',
}}
>
{cards}
</div>
);
}, areEqual);
@@ -1,61 +1,55 @@
import { Center } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import type { ListChildComponentProps } from 'react-window';
import { Stack } from '@mantine/core';
import { generatePath, useNavigate } from 'react-router-dom';
import { ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { Skeleton } from '/@/renderer/components/skeleton';
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 { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
import { CardRows } from '/@/renderer/components/card';
import { Skeleton } from '/@/renderer/components/skeleton';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardRows: CardRow<Album | AlbumArtist | Artist>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
isHidden?: boolean;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
}
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
background: var(--card-poster-bg);
border-radius: var(--card-poster-radius);
margin: 1rem;
overflow: hidden;
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
pointer-events: auto;
&:hover {
background: var(--card-poster-bg-hover);
.card-controls {
opacity: 0;
}
`;
const ImageSection = styled.div`
const LinkContainer = styled.div`
cursor: pointer;
`;
const ImageContainer = styled.div`
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);
&::before {
@@ -71,153 +65,99 @@ const ImageSection = styled.div`
content: '';
user-select: none;
}
`;
interface ImageProps {
height: number;
isLoading?: boolean;
}
&:hover {
&::before {
opacity: 0.5;
}
}
const Image = styled(SimpleImg)<ImageProps>`
border: 0;
border-radius: var(--card-poster-radius);
img {
object-fit: cover;
&:hover .card-controls {
opacity: 1;
}
`;
const ControlsContainer = styled.div`
position: absolute;
bottom: 0;
z-index: 50;
const Image = styled.img`
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
max-width: 100%;
height: auto;
object-fit: cover;
border: 0;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
const DetailContainer = styled.div`
margin-top: 0.5rem;
`;
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 = ({
listChildProps,
data,
columnIndex,
controls,
sizes,
isHidden,
}: BaseGridCardProps) => {
const navigate = useNavigate();
if (data) {
const path = generatePath(
controls.route.route,
controls.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
);
return (
<CardWrapper
key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={sizes.itemGap}
itemHeight={sizes.itemHeight}
itemWidth={sizes.itemWidth}
>
<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}
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
<LinkContainer onClick={() => navigate(path)}>
<ImageContainer>
<Image
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
src={data?.imageUrl}
/>
</DetailSection>
</StyledCard>
</CardWrapper>
<GridCardControls
handleFavorite={controls.handleFavorite}
handlePlayQueueAdd={controls.handlePlayQueueAdd}
itemData={data}
itemType={controls.itemType}
/>
</ImageContainer>
</LinkContainer>
<DetailContainer>
<CardRows
data={data}
rows={controls.cardRows}
/>
</DetailContainer>
</PosterCardContainer>
);
}
return (
<CardWrapper
<PosterCardContainer
key={`card-${columnIndex}-${listChildProps.index}`}
itemGap={sizes.itemGap}
itemHeight={sizes.itemHeight}
itemWidth={sizes.itemWidth}
$isHidden={isHidden}
>
<StyledCard>
<Skeleton
visible
radius="sm"
>
<ImageSection style={{ height: `${sizes.itemWidth}px` }} />
</Skeleton>
<DetailSection>
{controls.cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
<Skeleton
visible
radius="sm"
>
<ImageContainer />
</Skeleton>
<DetailContainer>
<Stack spacing="sm">
{controls.cardRows.map((row) => (
<Skeleton
key={`row-${row.property}-${columnIndex}`}
height={20}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
key={row.arrayProperty}
visible
height={14}
radius="sm"
/>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
</Stack>
</DetailContainer>
</PosterCardContainer>
);
};
@@ -5,14 +5,8 @@ import type { FixedSizeListProps } from 'react-window';
import { FixedSizeList } from 'react-window';
import styled from 'styled-components';
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
import type {
CardRow,
LibraryItem,
ListDisplayType,
CardRoute,
PlayQueueAddOptions,
} from '/@/renderer/types';
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
import type { CardRow, ListDisplayType, CardRoute, PlayQueueAddOptions } from '/@/renderer/types';
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
const createItemData = memoize(
(
@@ -27,10 +21,12 @@ const createItemData = memoize(
itemWidth,
route,
handlePlayQueueAdd,
handleFavorite,
) => ({
cardRows,
columnCount,
display,
handleFavorite,
handlePlayQueueAdd,
itemCount,
itemData,
@@ -56,6 +52,7 @@ export const VirtualGridWrapper = ({
columnCount,
rowCount,
initialScrollOffset,
handleFavorite,
handlePlayQueueAdd,
itemData,
route,
@@ -65,6 +62,7 @@ export const VirtualGridWrapper = ({
cardRows: CardRow<Album | AlbumArtist | Artist>[];
columnCount: number;
display: ListDisplayType;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any[];
itemGap: number;
@@ -87,6 +85,7 @@ export const VirtualGridWrapper = ({
itemWidth,
route,
handlePlayQueueAdd,
handleFavorite,
);
const memoizedOnScroll = createScrollHandler(onScroll);
@@ -11,8 +11,9 @@ import debounce from 'lodash/debounce';
import type { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
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 { LibraryItem } from '/@/renderer/api/types';
export type VirtualInfiniteGridRef = {
resetLoadMoreItemsCache: () => void;
@@ -24,22 +25,16 @@ interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSi
cardRows: CardRow<any>[];
display?: ListDisplayType;
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemGap: number;
itemSize: number;
itemType: LibraryItem;
loading?: boolean;
minimumBatchSize?: number;
route?: CardRoute;
}
const constrainWidth = (width: number) => {
if (width < 1920) {
return width;
}
return 1920;
};
export const VirtualInfiniteGrid = forwardRef(
(
{
@@ -54,28 +49,30 @@ export const VirtualInfiniteGrid = forwardRef(
handlePlayQueueAdd,
minimumBatchSize,
fetchFn,
loading,
initialScrollOffset,
handleFavorite,
height,
width,
}: VirtualGridProps,
ref: Ref<VirtualInfiniteGridRef>,
) => {
const [itemData, setItemData] = useState<any[]>([]);
const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(null);
const [itemData, setItemData] = useState<any[]>([]);
const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = Math.floor(
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2),
);
const itemsPerRow = itemSize;
const widthPerItem = Number(width) / itemsPerRow;
const itemHeight = widthPerItem + cardRows.length * 26;
return {
columnCount: itemsPerRow,
itemHeight: itemSize! + cardRows.length * 22 + itemGap,
itemWidth: itemSize! + itemGap,
itemHeight,
rowCount: Math.ceil(itemCount / itemsPerRow),
};
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
}, [cardRows.length, itemCount, itemSize, width]);
const isItemLoaded = useCallback(
(index: number) => {
@@ -111,7 +108,7 @@ export const VirtualInfiniteGrid = forwardRef(
setItemData(newData);
},
[columnCount, fetchFn, itemData],
[columnCount, fetchFn, itemData, setItemData],
);
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
@@ -120,52 +117,57 @@ export const VirtualInfiniteGrid = forwardRef(
resetLoadMoreItemsCache: () => {
if (loader.current) {
loader.current.resetloadMoreItemsCache(false);
setItemData(() => []);
setItemData([]);
}
},
scrollTo: (index: number) => {
listRef.current.scrollToItem(index);
listRef?.current?.scrollToItem(index);
},
setItemData: (data: any[]) => {
setItemData(data);
},
}));
if (loading) return null;
return (
<InfiniteLoader
ref={loader}
isItemLoaded={(index) => isItemLoaded(index)}
itemCount={itemCount || 0}
loadMoreItems={debouncedLoadMoreItems}
minimumBatchSize={minimumBatchSize}
threshold={30}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<VirtualGridWrapper
cardRows={cardRows}
columnCount={columnCount}
display={display || ListDisplayType.CARD}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={initialScrollOffset}
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap}
itemHeight={itemHeight + itemGap / 2}
itemType={itemType}
itemWidth={itemSize}
refInstance={(list) => {
infiniteLoaderRef(list);
listRef.current = list;
}}
route={route}
rowCount={rowCount}
width={width}
onItemsRendered={onItemsRendered}
onScroll={onScroll}
/>
)}
</InfiniteLoader>
<>
<InfiniteLoader
ref={loader}
isItemLoaded={(index) => isItemLoaded(index)}
itemCount={itemCount || 0}
loadMoreItems={debouncedLoadMoreItems}
minimumBatchSize={minimumBatchSize}
threshold={30}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<VirtualGridWrapper
cardRows={cardRows}
columnCount={columnCount}
display={display || ListDisplayType.CARD}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={initialScrollOffset}
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap}
itemHeight={itemHeight}
itemType={itemType}
itemWidth={itemSize}
refInstance={(list) => {
infiniteLoaderRef(list);
listRef.current = list;
}}
route={route}
rowCount={rowCount}
width={width}
onItemsRendered={onItemsRendered}
onScroll={onScroll}
/>
)}
</InfiniteLoader>
</>
);
},
);
@@ -25,14 +25,14 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
<Text
$secondary
overflow="hidden"
size="sm"
size="md"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="sm"
size="md"
style={{ display: 'inline-block' }}
>
,
@@ -43,8 +43,8 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
$secondary
component={Link}
overflow="hidden"
size="sm"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
size="md"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: item.id,
})}
>
@@ -25,14 +25,14 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
<Text
$secondary
overflow="hidden"
size="sm"
size="md"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="sm"
size="md"
style={{ display: 'inline-block' }}
>
,
@@ -43,7 +43,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
$secondary
component={Link}
overflow="hidden"
size="sm"
size="md"
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
artistId: item.id,
})}
@@ -5,7 +5,6 @@ import { motion } from 'framer-motion';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { SimpleImg } from 'react-simple-img';
import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
@@ -23,6 +22,7 @@ const CellContainer = styled(motion.div)<{ height: number }>`
width: 100%;
max-width: 100%;
height: 100%;
letter-spacing: 0.5px;
`;
const ImageWrapper = styled.div`
@@ -41,10 +41,8 @@ const MetadataWrapper = styled.div`
width: 100%;
`;
const StyledImage = styled(SimpleImg)`
img {
object-fit: cover;
}
const StyledImage = styled.img`
object-fit: cover;
`;
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
@@ -105,14 +103,14 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
<MetadataWrapper>
<Text
overflow="hidden"
size="sm"
size="md"
>
{value.name}
</Text>
<Text
$secondary
overflow="hidden"
size="sm"
size="md"
>
{artists?.length ? (
artists.map((artist: Artist | AlbumArtist, index: number) => (
@@ -123,9 +121,9 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
$secondary
component={Link}
overflow="hidden"
size="sm"
size="md"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
@@ -0,0 +1,101 @@
import type { ICellRendererParams } from '@ag-grid-community/core';
import { RiHeartFill, RiHeartLine } from 'react-icons/ri';
import { Button } from '/@/renderer/components/button';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useMutation } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { RawFavoriteResponse, FavoriteArgs, LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
const useCreateFavorite = () => {
const server = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
mutationFn: (args) => api.controller.createFavorite({ ...args, server }),
onSuccess: (_data, variables) => {
for (const id of variables.query.id) {
// Set the userFavorite property to true for the album in the album list data store
if (variables.query.type === LibraryItem.ALBUM) {
setAlbumListData(id, { userFavorite: true });
}
}
},
});
};
const useDeleteFavorite = () => {
const server = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
mutationFn: (args) => api.controller.deleteFavorite({ ...args, server }),
onSuccess: (_data, variables) => {
for (const id of variables.query.id) {
// Set the userFavorite property to false for the album in the album list data store
if (variables.query.type === LibraryItem.ALBUM) {
setAlbumListData(id, { userFavorite: false });
}
}
},
});
};
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'};
width: 100%;
height: 100%;
letter-spacing: 0.5px;
`;
type Options = {
@@ -60,7 +61,7 @@ export const GenericCell = (
$secondary={!primary}
component={Link}
overflow="hidden"
size="sm"
size="md"
to={displayedValue.link}
>
{isLink ? displayedValue.value : displayedValue}
@@ -69,7 +70,7 @@ export const GenericCell = (
<Text
$secondary={!primary}
overflow="hidden"
size="sm"
size="md"
>
{displayedValue}
</Text>
@@ -11,14 +11,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
<Text
$secondary
overflow="hidden"
size="sm"
size="md"
>
{value?.map((item: Artist | AlbumArtist, index: number) => (
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
{index > 0 && (
<Text
$secondary
size="sm"
size="md"
style={{ display: 'inline-block' }}
>
,
@@ -29,7 +29,7 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
$secondary
component={Link}
overflow="hidden"
size="sm"
size="md"
to="/"
>
{item.name || '—'}
@@ -0,0 +1,58 @@
import { MouseEvent } from 'react';
import type { ICellRendererParams } from '@ag-grid-community/core';
import { Rating } from '/@/renderer/components/rating';
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { useUpdateRating } from '/@/renderer/components/virtual-table/hooks/use-rating';
export const RatingCell = ({ value, node }: ICellRendererParams) => {
const updateRatingMutation = useUpdateRating();
const handleUpdateRating = (rating: number) => {
if (!value) return;
updateRatingMutation.mutate(
{
_serverId: value?.serverId,
query: {
item: [value],
rating,
},
},
{
onSuccess: () => {
node.setData({ ...node.data, userRating: rating });
},
},
);
};
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
updateRatingMutation.mutate(
{
_serverId: value?.serverId,
query: {
item: [value],
rating: 0,
},
},
{
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 { AiOutlineNumber } from 'react-icons/ai';
import { FiClock } from 'react-icons/fi';
import { RiHeartLine, RiStarLine } from 'react-icons/ri';
import styled from 'styled-components';
import { _Text } from '/@/renderer/components/text';
type Presets = 'duration' | 'rowIndex';
type Presets = 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
type Options = {
children?: ReactNode;
@@ -12,7 +14,7 @@ type Options = {
preset?: Presets;
};
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
const HeaderWrapper = styled.div<{ position: Options['position'] }>`
display: flex;
justify-content: ${(props) =>
props.position === 'right'
@@ -25,17 +27,63 @@ const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
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 = (
{ displayName }: IHeaderParams,
{ preset, children, position }: Options,
) => {
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 = {
@@ -0,0 +1,15 @@
import { MutableRefObject } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useClickOutside } from '@mantine/hooks';
export const useClickOutsideDeselect = (tableRef: MutableRefObject<AgGridReactType | null>) => {
const handleDeselect = () => {
if (tableRef.current) {
tableRef.current.api.deselectAll();
}
};
const ref = useClickOutside(handleDeselect);
return ref;
};
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { useInView } from 'framer-motion';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/renderer/types';
export const useFixedTableHeader = () => {
const intersectRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const { windowBarStyle } = useWindowSettings();
const isNotPastTableIntersection = useInView(intersectRef, {
margin: windowBarStyle === Platform.WEB ? '-68px 0px 0px 0px' : '-98px 0px 0px 0px',
});
const tableInView = useInView(tableContainerRef, {
margin: '-128px 0px 0px 0px',
});
useEffect(() => {
const header = document.querySelector('main .ag-header');
const root = document.querySelector('main .ag-root');
if (isNotPastTableIntersection || !tableInView) {
if (windowBarStyle !== Platform.WEB) {
header?.classList.remove('window-frame');
}
header?.classList.remove('ag-header-fixed');
root?.classList.remove('ag-header-fixed-margin');
} else {
if (windowBarStyle !== Platform.WEB) {
header?.classList.add('window-frame');
}
header?.classList.add('ag-header-fixed');
root?.classList.add('ag-header-fixed-margin');
}
}, [isNotPastTableIntersection, tableInView, windowBarStyle]);
return { intersectRef, tableContainerRef };
};
@@ -0,0 +1,130 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { HTTPError } from 'ky';
import { api } from '/@/renderer/api';
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
import { queryKeys } from '/@/renderer/api/query-keys';
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
import {
RawRatingResponse,
RatingArgs,
Album,
AlbumArtist,
LibraryItem,
AnyLibraryItems,
} from '/@/renderer/api/types';
import {
useCurrentServer,
useSetAlbumListItemDataById,
useSetQueueRating,
useAuthStore,
} from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
export const useUpdateRating = () => {
const queryClient = useQueryClient();
const currentServer = useCurrentServer();
const setAlbumListData = useSetAlbumListItemDataById();
const setQueueRating = useSetQueueRating();
return useMutation<
RawRatingResponse,
HTTPError,
Omit<RatingArgs, 'server'>,
{ previous: { items: AnyLibraryItems } | undefined }
>({
mutationFn: (args) => {
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
return api.controller.updateRating({ ...args, server });
},
onError: (_error, _variables, context) => {
for (const item of context?.previous?.items || []) {
switch (item.itemType) {
case LibraryItem.ALBUM:
setAlbumListData(item.id, { userRating: item.userRating });
break;
case LibraryItem.SONG:
setQueueRating([item.id], item.userRating);
break;
}
}
},
onMutate: (variables) => {
for (const item of variables.query.item) {
switch (item.itemType) {
case LibraryItem.ALBUM:
setAlbumListData(item.id, { userRating: variables.query.rating });
break;
case LibraryItem.SONG:
setQueueRating([item.id], variables.query.rating);
break;
}
}
return { previous: { items: variables.query.item } };
},
onSuccess: (_data, variables) => {
// We only need to set if we're already on the album detail page
const isAlbumDetailPage =
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
if (isAlbumDetailPage) {
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
// We only need to set if we're already on the album detail page
const isAlbumArtistDetailPage =
variables.query.item.length === 1 &&
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
if (isAlbumArtistDetailPage) {
const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
id: albumArtistId,
});
const previous = queryClient.getQueryData<any>(queryKey);
if (previous) {
switch (serverType) {
case ServerType.NAVIDROME:
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.SUBSONIC:
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
...previous,
userRating: variables.query.rating,
});
break;
case ServerType.JELLYFIN:
// Jellyfin does not support ratings
break;
}
}
}
},
});
};
+198 -21
View File
@@ -1,16 +1,21 @@
import type { Ref } from 'react';
import { forwardRef, useRef } from 'react';
import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
import type {
ICellRendererParams,
ValueGetterParams,
IHeaderParams,
ValueFormatterParams,
ColDef,
ColumnMovedEvent,
NewColumnsLoadedEvent,
GridReadyEvent,
GridSizeChangedEvent,
} from '@ag-grid-community/core';
import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useMergedRef } from '@mantine/hooks';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import styled from 'styled-components';
@@ -23,9 +28,13 @@ import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers
import { AppRoute } from '/@/renderer/router/routes';
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
import { TableColumn } from '/@/renderer/types';
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
export * from './table-config-dropdown';
export * from './table-pagination';
export * from './hooks/use-fixed-table-header';
export * from './hooks/use-click-outside-deselect';
const TableWrapper = styled.div`
display: flex;
@@ -34,6 +43,8 @@ const TableWrapper = styled.div`
height: 100%;
`;
dayjs.extend(relativeTime);
const tableColumns: { [key: string]: ColDef } = {
album: {
cellRenderer: (params: ICellRendererParams) =>
@@ -49,6 +60,7 @@ const tableColumns: { [key: string]: ColDef } = {
value: params.data?.album,
}
: undefined,
width: 200,
},
albumArtist: {
cellRenderer: AlbumArtistCell,
@@ -56,6 +68,7 @@ const tableColumns: { [key: string]: ColDef } = {
headerName: 'Album Artist',
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumArtists : undefined,
width: 150,
},
albumCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -63,36 +76,43 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'albumCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Albums',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.albumCount : undefined),
width: 80,
},
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
width: 150,
},
biography: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.BIOGRAPHY,
field: 'biography',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
headerName: 'Biography',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : undefined),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
width: 200,
},
bitRate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.BIT_RATE,
field: 'bitRate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
width: 90,
},
bpm: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.BPM,
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'BPM',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
width: 60,
},
channels: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -100,21 +120,26 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'channels',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.channels : undefined),
width: 100,
},
comment: {
cellRenderer: GenericCell,
colId: TableColumn.COMMENT,
headerName: 'Note',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
width: 150,
},
dateAdded: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DATE_ADDED,
field: 'createdAt',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'left' }),
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Date Added',
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.createdAt : undefined),
width: 130,
},
discNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -122,9 +147,9 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'discNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
initialWidth: 75,
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.discNumber : undefined),
width: 60,
},
duration: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -132,28 +157,35 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'duration',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
initialWidth: 100,
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.duration : undefined),
width: 70,
},
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
width: 100,
},
lastPlayedAt: {
cellRenderer: GenericCell,
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.LAST_PLAYED,
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Last Played',
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.lastPlayedAt : undefined,
width: 130,
},
path: {
cellRenderer: GenericCell,
colId: TableColumn.PATH,
headerName: 'Path',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
width: 200,
},
playCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -161,7 +193,9 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'playCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Plays',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.playCount : undefined),
width: 90,
},
releaseDate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -169,8 +203,11 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseDate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
valueFormatter: (params: ValueFormatterParams) => params.value?.split('T')[0],
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseDate : undefined),
width: 130,
},
releaseYear: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -178,18 +215,20 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'releaseYear',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseYear : undefined),
width: 80,
},
rowIndex: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
colId: TableColumn.ROW_INDEX,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'left', preset: 'rowIndex' }),
initialWidth: 50,
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
suppressSizeToFit: true,
valueGetter: (params) => {
return (params.node?.rowIndex || 0) + 1;
},
width: 65,
},
songCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -197,7 +236,9 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'songCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Songs',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.songCount : undefined),
width: 80,
},
title: {
cellRenderer: (params: ICellRendererParams) =>
@@ -206,12 +247,14 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'name',
headerName: 'Title',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
width: 250,
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
initialWidth: 500,
minWidth: 150,
valueGetter: (params: ValueGetterParams) =>
params.data
? {
@@ -221,9 +264,10 @@ const tableColumns: { [key: string]: ColDef } = {
imageUrl: params.data?.imageUrl,
name: params.data?.name,
rowHeight: params.node?.rowHeight,
type: params.data?.type,
type: params.data?.serverType,
}
: undefined,
width: 250,
},
trackNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
@@ -231,9 +275,34 @@ const tableColumns: { [key: string]: ColDef } = {
field: 'trackNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
initialWidth: 75,
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.trackNumber : undefined),
width: 80,
},
userFavorite: {
cellClass: (params) => (params.value ? 'visible ag-cell-favorite' : 'ag-cell-favorite'),
cellRenderer: FavoriteCell,
colId: TableColumn.USER_FAVORITE,
field: 'userFavorite',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
headerName: 'Favorite',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.userFavorite : undefined,
width: 50,
},
userRating: {
cellClass: (params) => (params.value?.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating'),
cellRenderer: RatingCell,
colId: TableColumn.USER_RATING,
field: 'userRating',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95,
},
};
@@ -256,20 +325,128 @@ export const getColumnDefs = (columns: PersistedTableColumn[]) => {
return columnDefs;
};
interface VirtualTableProps extends AgGridReactProps {
autoFitColumns?: boolean;
autoHeight?: boolean;
deselectOnClickOutside?: boolean;
transparentHeader?: boolean;
}
export const VirtualTable = forwardRef(
({ ...rest }: AgGridReactProps, ref: Ref<AgGridReactType | null>) => {
(
{
autoFitColumns,
deselectOnClickOutside,
autoHeight,
transparentHeader,
onColumnMoved,
onNewColumnsLoaded,
onGridReady,
onGridSizeChanged,
...rest
}: VirtualTableProps,
ref: Ref<AgGridReactType | null>,
) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const deselectRef = useClickOutside(() => {
if (tableRef?.current?.api && deselectOnClickOutside) {
tableRef?.current?.api?.deselectAll();
}
});
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
// Auto fit columns on column change
useEffect(() => {
if (!tableRef?.current?.api) return;
if (autoFitColumns && tableRef?.current?.api) {
tableRef?.current?.api?.sizeColumnsToFit?.();
}
}, [autoFitColumns]);
// Reset row heights on row height change
useEffect(() => {
if (!tableRef?.current?.api) return;
tableRef?.current?.api?.resetRowHeights();
tableRef?.current?.api?.redrawRows();
}, [rest.rowHeight]);
const handleColumnMoved = useCallback(
(e: ColumnMovedEvent) => {
if (!e?.api) return;
onColumnMoved?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onColumnMoved],
);
const handleNewColumnsLoaded = useCallback(
(e: NewColumnsLoadedEvent) => {
if (!e?.api) return;
onNewColumnsLoaded?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onNewColumnsLoaded],
);
const handleGridReady = useCallback(
(e: GridReadyEvent) => {
if (!e?.api) return;
onGridReady?.(e);
if (autoHeight) e.api.setDomLayout('autoHeight');
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoHeight, autoFitColumns, onGridReady],
);
const handleGridSizeChanged = useCallback(
(e: GridSizeChangedEvent) => {
if (!e?.api) return;
onGridSizeChanged?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onGridSizeChanged],
);
return (
<TableWrapper className="ag-theme-alpine-dark">
<TableWrapper
ref={deselectRef}
className={
transparentHeader ? 'ag-header-transparent ag-theme-alpine-dark' : 'ag-theme-alpine-dark'
}
>
<AgGridReact
ref={mergedRef}
animateRows
maintainColumnOrder
suppressAsyncEvents
suppressContextMenu
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressScrollOnNewData
blockLoadDebounceMillis={200}
cacheBlockSize={300}
cacheOverflowSize={1}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
headerHeight={36}
rowBuffer={30}
rowSelection="multiple"
{...rest}
onColumnMoved={handleColumnMoved}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChanged}
onNewColumnsLoaded={handleNewColumnsLoaded}
/>
</TableWrapper>
);
@@ -1,11 +1,10 @@
import type { ChangeEvent } from 'react';
import { Stack } from '@mantine/core';
import { MultiSelect } from '/@/renderer/components/select';
import { Slider } from '/@/renderer/components/slider';
import { Switch } from '/@/renderer/components/switch';
import { Text } from '/@/renderer/components/text';
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option';
export const SONG_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
@@ -28,9 +27,9 @@ export const SONG_TABLE_COLUMNS = [
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Path', value: TableColumn.PATH },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
// { label: 'Favorite', value: TableColumn.FAVORITE },
// { label: 'Rating', value: TableColumn.RATING },
// { label: 'Size', value: TableColumn.SIZE },
{ label: 'Size', value: TableColumn.SIZE },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
// { label: 'Skip', value: TableColumn.SKIP },
];
@@ -47,6 +46,8 @@ export const ALBUM_TABLE_COLUMNS = [
{ label: 'Last Played', value: TableColumn.LAST_PLAYED },
{ label: 'Date Added', value: TableColumn.DATE_ADDED },
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
];
export const ALBUMARTIST_TABLE_COLUMNS = [
@@ -60,6 +61,8 @@ export const ALBUMARTIST_TABLE_COLUMNS = [
{ label: 'Plays', value: TableColumn.PLAY_COUNT },
{ label: 'Album Count', value: TableColumn.ALBUM_COUNT },
{ label: 'Song Count', value: TableColumn.SONG_COUNT },
{ label: 'Favorite', value: TableColumn.USER_FAVORITE },
{ label: 'Rating', value: TableColumn.USER_RATING },
];
export const PLAYLIST_TABLE_COLUMNS = [
@@ -164,45 +167,49 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
};
return (
<Stack
p="1rem"
spacing="xl"
>
<Stack spacing="xs">
<Text>Table Columns</Text>
<MultiSelect
clearable
data={SONG_TABLE_COLUMNS}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="top"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Stack>
<Stack spacing="xs">
<Text>Row Height</Text>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
max={100}
min={25}
sx={{ width: 150 }}
onChangeEnd={handleUpdateRowHeight}
/>
</Stack>
<Stack spacing="xs">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Stack>
<Stack spacing="xs">
<Text>Follow Current Song</Text>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Stack>
</Stack>
<>
<Option>
<Option.Label>Auto-fit Columns</Option.Label>
<Option.Control>
<Switch
defaultChecked={tableConfig[type]?.autoFit}
onChange={handleUpdateAutoFit}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Follow current song</Option.Label>
<Option.Control>
<Switch
defaultChecked={tableConfig[type]?.followCurrentSong}
onChange={handleUpdateFollow}
/>
</Option.Control>
</Option>
<Option>
<Option.Control>
<Slider
defaultValue={tableConfig[type]?.rowHeight}
label={(value) => `Item size: ${value}`}
max={100}
min={25}
w="100%"
onChangeEnd={handleUpdateRowHeight}
/>
</Option.Control>
</Option>
<Option>
<Option.Control>
<MultiSelect
clearable
data={SONG_TABLE_COLUMNS}
defaultValue={tableConfig[type]?.columns.map((column) => column.column)}
dropdownPosition="bottom"
width={300}
onChange={handleAddOrRemoveColumns}
/>
</Option.Control>
</Option>
</>
);
};
@@ -12,17 +12,18 @@ import { Popover } from '/@/renderer/components/popover';
import { Text } from '/@/renderer/components/text';
import { useContainerQuery } from '/@/renderer/hooks';
import { TablePagination as TablePaginationType } from '/@/renderer/types';
import { ListKey } from '/@/renderer/store';
interface TablePaginationProps {
id?: string;
pageKey: ListKey;
pagination: TablePaginationType;
setIdPagination?: (id: string, pagination: Partial<TablePaginationType>) => void;
setPagination?: (pagination: Partial<TablePaginationType>) => void;
setPagination?: (args: { data: Partial<TablePaginationType>; key: ListKey }) => void;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const TablePagination = ({
id,
pageKey,
tableRef,
pagination,
setPagination,
@@ -40,8 +41,8 @@ export const TablePagination = ({
const handlePagination = (index: number) => {
const newPage = index - 1;
tableRef.current?.api.paginationGoToPage(newPage);
setPagination?.({ currentPage: newPage });
setIdPagination?.(id || '', { currentPage: newPage });
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
setIdPagination?.(pageKey || '', { currentPage: newPage });
};
const handleGoSubmit = goToForm.onSubmit((values) => {
@@ -52,8 +53,8 @@ export const TablePagination = ({
const newPage = values.pageNumber - 1;
tableRef.current?.api.paginationGoToPage(newPage);
setPagination?.({ currentPage: newPage });
setIdPagination?.(id || '', { currentPage: newPage });
setPagination?.({ data: { currentPage: newPage }, key: pageKey });
setIdPagination?.(pageKey || '', { currentPage: newPage });
});
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage + 1;
@@ -103,15 +104,13 @@ export const TablePagination = ({
trapFocus
opened={isGoToPageOpen}
position="bottom-start"
transition="fade"
onClose={() => handlers.close()}
>
<Popover.Target>
<Button
compact
radius="sm"
size="lg"
sx={{ height: '32px', padding: 0, width: '32px' }}
size="sm"
sx={{ height: '26px', padding: '0', width: '26px' }}
tooltip={{ label: 'Go to page' }}
variant="default"
onClick={() => handlers.toggle()}
@@ -143,10 +142,10 @@ export const TablePagination = ({
noWrap
$hideDividers={!containerQuery.isSm}
boundaries={1}
page={pagination.currentPage + 1}
radius="sm"
siblings={containerQuery.isMd ? 2 : containerQuery.isSm ? 1 : 0}
total={pagination.totalPages - 1}
value={pagination.currentPage + 1}
onChange={handlePagination}
/>
</Group>
@@ -1,7 +1,7 @@
import { Box, Center, Divider, Group, Stack } from '@mantine/core';
import { Box, Center, Group, Stack } from '@mantine/core';
import type { FallbackProps } from 'react-error-boundary';
import { RiErrorWarningLine, RiArrowLeftLine } from 'react-icons/ri';
import { useNavigate, useRouteError } from 'react-router';
import { RiErrorWarningLine } from 'react-icons/ri';
import { useRouteError } from 'react-router';
import styled from 'styled-components';
import { Button, Text } from '/@/renderer/components';
@@ -9,7 +9,9 @@ const Container = styled(Box)`
background: var(--main-bg);
`;
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
export const ErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
const error = useRouteError() as any;
return (
<Container>
<Center sx={{ height: '100vh' }}>
@@ -33,51 +35,3 @@ export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
</Container>
);
};
export const RouteErrorBoundary = () => {
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const handleReload = () => {
navigate(0);
};
const handleReturn = () => {
navigate(-1);
};
return (
<Container>
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group grow>
<Button
leftIcon={<RiArrowLeftLine />}
sx={{ flex: 0.5 }}
variant="default"
onClick={handleReturn}
>
Go back
</Button>
<Button
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Container>
);
};
@@ -22,7 +22,7 @@ export const MpvRequired = () => {
return (
<>
<Text size="lg">Set your MPV executable location below and restart the application.</Text>
<Text>Set your MPV executable location below and restart the application.</Text>
<Text>
MPV is available at the following:{' '}
<a
@@ -0,0 +1,71 @@
import { Center, Stack, Group, Divider, Box } from '@mantine/core';
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line } from 'react-icons/ri';
import { useNavigate, useRouteError } from 'react-router';
import { Button, Text } from '/@/renderer/components';
import { AppRoute } from '/@/renderer/router/routes';
const RouteErrorBoundary = () => {
const navigate = useNavigate();
const error = useRouteError() as any;
console.log('error', error);
const handleReload = () => {
navigate(0);
};
const handleReturn = () => {
navigate(-1);
};
const handleHome = () => {
navigate(AppRoute.HOME);
};
return (
<Box bg="var(--main-bg)">
<Center sx={{ height: '100vh' }}>
<Stack sx={{ maxWidth: '50%' }}>
<Group>
<Button
px={10}
variant="subtle"
onClick={handleReturn}
>
<RiArrowLeftSLine size={20} />
</Button>
<RiErrorWarningLine
color="var(--danger-color)"
size={30}
/>
<Text size="lg">Something went wrong</Text>
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group
grow
spacing="sm"
>
<Button
leftIcon={<RiHome4Line />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
onClick={handleHome}
>
Go home
</Button>
<Button
size="md"
variant="filled"
onClick={handleReload}
>
Reload
</Button>
</Group>
</Stack>
</Center>
</Box>
);
};
export default RouteErrorBoundary;
@@ -6,11 +6,11 @@ export const ServerCredentialRequired = () => {
return (
<>
<Text size="lg">
<Text>
The selected server &apos;{currentServer?.name}&apos; requires an additional login to
access.
</Text>
<Text size="lg">
<Text>
Add your credentials in the &apos;manage servers&apos; menu or switch to a different server.
</Text>
</>
@@ -1,10 +1,24 @@
import { Text } from '/@/renderer/components';
import { RiMenuFill } from 'react-icons/ri';
import { Button, DropdownMenu, Text } from '/@/renderer/components';
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
export const ServerRequired = () => {
return (
<>
<Text size="xl">No server selected.</Text>
<Text>Add or select a server in the file menu.</Text>
<Text>No server selected.</Text>
<DropdownMenu>
<DropdownMenu.Target>
<Button
leftIcon={<RiMenuFill />}
variant="filled"
>
Open menu
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<AppMenu />
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
);
};
@@ -24,7 +24,12 @@ const ActionRequiredRoute = () => {
const getMpvPath = async () => {
if (!isElectron()) return setIsMpvRequired(false);
const mpvPath = await localSettings.get('mpv_path');
return setIsMpvRequired(!mpvPath);
if (mpvPath) {
return setIsMpvRequired(false);
}
return setIsMpvRequired(true);
};
getMpvPath();
@@ -48,6 +53,8 @@ const ActionRequiredRoute = () => {
},
];
console.log(checks);
const canReturnHome = checks.every((c) => c.valid);
const displayedCheck = checks.find((c) => !c.valid);
@@ -1,53 +1,56 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import {
Button,
DropdownMenu,
getColumnDefs,
GridCarousel,
Text,
TextTitle,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components';
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
import { ColDef, RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group, Stack } from '@mantine/core';
import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import sortBy from 'lodash/sortBy';
import { RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { useParams } from 'react-router';
import { RiDiscFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
import { useSongListStore } from '/@/renderer/store';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { openContextMenu } from '/@/renderer/features/context-menu';
import { LibraryItem, Play } from '/@/renderer/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
useHandleGeneralContextMenu,
useHandleTableContextMenu,
} from '/@/renderer/features/context-menu';
import { Play, ServerType, TableColumn } from '/@/renderer/types';
import {
ALBUM_CONTEXT_MENU_ITEMS,
SONG_CONTEXT_MENU_ITEMS,
} from '/@/renderer/features/context-menu/context-menu-items';
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player';
const isFullWidthRow = (node: RowNode) => {
return node.id?.includes('disc-');
};
const ContentContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
max-width: 1920px;
padding: 1rem 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%);
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
}
.ag-header-container {
z-index: 1000;
}
.ag-header-cell-resize {
top: 25%;
width: 7px;
height: 50%;
background-color: rgb(70, 70, 70, 20%);
.ag-header {
margin-bottom: 0.5rem;
}
`;
@@ -61,22 +64,83 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const cq = useContainerQuery();
const handlePlayQueueAdd = usePlayQueueAdd();
const page = useSongListStore();
// TODO: Make this customizable
const columnDefs: ColDef[] = useMemo(() => {
const userRatingColumn =
detailQuery?.data?.serverType !== ServerType.JELLYFIN
? [
{
column: TableColumn.USER_RATING,
width: 0,
},
]
: [];
const columnDefs: ColDef[] = useMemo(
() =>
getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
[page.table.columns],
);
const cols: PersistedTableColumn[] = [
{
column: TableColumn.TRACK_NUMBER,
width: 0,
},
{
column: TableColumn.TITLE_COMBINED,
width: 0,
},
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
{
column: TableColumn.DURATION,
width: 0,
},
{
column: TableColumn.BIT_RATE,
width: 0,
},
{
column: TableColumn.PLAY_COUNT,
width: 0,
},
{
column: TableColumn.LAST_PLAYED,
width: 0,
},
...userRatingColumn,
{
column: TableColumn.USER_FAVORITE,
width: 0,
},
];
return getColumnDefs(cols).filter((c) => c.colId !== 'album' && c.colId !== 'artist');
}, [detailQuery?.data?.serverType]);
const getRowHeight = useCallback((params: RowHeightParams) => {
if (isFullWidthRow(params.node)) {
return 45;
}
return 60;
}, []);
const songsRowData = useMemo(() => {
if (!detailQuery.data?.songs) {
return [];
}
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
if (uniqueDiscNumbers.size === 1) {
return detailQuery.data?.songs;
}
const rowData: (QueueSong | { id: string; name: string })[] = [];
for (const discNumber of uniqueDiscNumbers.values()) {
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
rowData.push({ id: `disc-${discNumber}`, name: `DISC ${discNumber}` });
rowData.push(...songsByDiscNumber);
}
return rowData;
}, [detailQuery.data?.songs]);
const [pagination, setPagination] = useSetState({
artist: 0,
});
@@ -134,8 +198,8 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
},
title: (
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
More from this artist
</TextTitle>
@@ -153,98 +217,150 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
});
};
const handleContextMenu = (e: CellContextMenuEvent) => {
if (!e.event) return;
const clickEvent = e.event as MouseEvent;
clickEvent.preventDefault();
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
const selectedNodes = e.api.getSelectedNodes();
const selectedIds = selectedNodes.map((node) => node.data.id);
let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data);
if (!selectedIds.includes(e.data.id)) {
e.api.deselectAll();
e.node.setSelected(true);
selectedRows = [e.data];
}
openContextMenu({
data: selectedRows,
menuItems: SONG_CONTEXT_MENU_ITEMS,
type: LibraryItem.SONG,
xPos: clickEvent.clientX,
yPos: clickEvent.clientY,
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
if (!e.data) return;
handlePlayQueueAdd?.({
byData: [e.data],
play: playButtonBehavior,
});
};
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const handleFavorite = () => {
if (!detailQuery?.data) return;
if (detailQuery.data.userFavorite) {
deleteFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
});
} else {
createFavoriteMutation.mutate({
query: {
id: [detailQuery.data.id],
type: LibraryItem.ALBUM,
},
});
}
};
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
ALBUM_CONTEXT_MENU_ITEMS,
);
return (
<ContentContainer>
<Group
pb="2rem"
pt="1rem"
spacing="lg"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
disabled
variant="subtle"
>
<RiHeartLine size={20} />
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Box component="section">
<Group
ref={showGenres ? null : intersectRef}
className="test"
py="1rem"
spacing="md"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
loading={createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading}
variant="subtle"
onClick={handleFavorite}
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
</Group>
<VirtualTable
ref={tableRef}
animateRows
detailRowAutoHeight
maintainColumnOrder
suppressCellFocus
suppressCopyRowsToClipboard
suppressLoadingOverlay
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.id}
rowData={detailQuery.data?.songs}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onGridReady={(params) => {
params.api.setDomLayout('autoHeight');
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={(params) => {
params.api.sizeColumnsToFit();
}}
/>
</Box>
{showGenres && (
<Box
ref={showGenres ? intersectRef : null}
component="section"
py="1rem"
>
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius={0}
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, { albumId })}
variant="outline"
>
{genre.name}
</Button>
))}
</Group>
</Box>
)}
<Box ref={tableContainerRef}>
<VirtualTable
ref={tableRef}
autoFitColumns
autoHeight
deselectOnClickOutside
suppressCellFocus
suppressHorizontalScroll
suppressLoadingOverlay
suppressRowDrag
columnDefs={columnDefs}
enableCellChangeFlash={false}
fullWidthCellRenderer={(data: any) => {
if (!data.data) return null;
return (
<Group
align="center"
h="100%"
spacing="sm"
>
<RiDiscFill />
<Text>{data.data.name}</Text>
</Group>
);
}}
getRowHeight={getRowHeight}
getRowId={(data) => data.data.id}
isFullWidthRow={(data) => {
return isFullWidthRow(data.rowNode) || false;
}}
isRowSelectable={(data) => {
if (isFullWidthRow(data.data)) return false;
return true;
}}
rowData={songsRowData}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onRowDoubleClicked={handleRowDoubleClick}
/>
</Box>
<Stack
ref={cq.ref}
mt="5rem"
@@ -264,13 +380,14 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={carousel.data}
itemType={LibraryItem.ALBUM}
loading={carousel.loading}
pagination={carousel.pagination}
uniqueId={carousel.uniqueId}
@@ -2,12 +2,12 @@ import { Group, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react';
import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Text } from '/@/renderer/components';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { Button, Rating, Text } from '/@/renderer/components';
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
import { LibraryHeader } from '/@/renderer/features/shared';
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import { LibraryItem } from '/@/renderer/types';
import { formatDurationString } from '/@/renderer/utils';
interface AlbumDetailHeaderProps {
@@ -38,25 +38,65 @@ export const AlbumDetailHeader = forwardRef(
},
];
const updateRatingMutation = useUpdateRating();
const handleUpdateRating = (rating: number) => {
if (!detailQuery?.data) return;
updateRatingMutation.mutate({
_serverId: detailQuery?.data.serverId,
query: {
item: [detailQuery.data],
rating,
},
});
};
const handleClearRating = () => {
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
updateRatingMutation.mutate({
_serverId: detailQuery.data.serverId,
query: {
item: [detailQuery.data],
rating: 0,
},
});
};
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
return (
<Stack ref={cq.ref}>
<LibraryHeader
ref={ref}
background={background}
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.PLAYLISTS, type: LibraryItem.ALBUM }}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
>
<Stack mt="1rem">
<Group>
<Stack spacing="sm">
<Group spacing="sm">
{metadataItems.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Rating
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
value={detailQuery?.data?.userRating || 0}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</>
)}
</Group>
<Group
spacing="sm"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
@@ -64,29 +104,18 @@ export const AlbumDetailHeader = forwardRef(
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist, index) => (
<Fragment key={`artist-${artist.id}`}>
{index > 0 && (
<Text
sx={{
display: 'inline-block',
padding: '0 0.5rem',
}}
>
</Text>
)}
<Text
$link
component={Link}
fw="600"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</Fragment>
{detailQuery?.data?.albumArtists.map((artist) => (
<Button
key={`artist-${artist.id}`}
component={Link}
size="sm"
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="outline"
>
{artist.name}
</Button>
))}
</Group>
</Stack>

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