Compare commits

...

136 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
243 changed files with 16978 additions and 10755 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 -2837
View File
File diff suppressed because it is too large Load Diff
+16 -17
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.0.1-alpha4",
"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,17 +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",
"@tanstack/react-virtual": "^3.0.0-beta.34",
"@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",
@@ -293,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-alpha4",
"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,
};
+35 -3
View File
@@ -39,11 +39,19 @@ import type {
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>;
@@ -69,6 +77,8 @@ export type ControllerEndpoint = Partial<{
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>;
}>;
@@ -81,6 +91,7 @@ type ApiController = {
const endpoints: ApiController = {
jellyfin: {
addToPlaylist: jellyfinApi.addToPlaylist,
clearPlaylist: undefined,
createFavorite: jellyfinApi.createFavorite,
createPlaylist: jellyfinApi.createPlaylist,
@@ -104,12 +115,15 @@ const endpoints: ApiController = {
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
getSongDetail: undefined,
getSongList: jellyfinApi.getSongList,
getTopSongs: undefined,
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,
@@ -135,6 +149,8 @@ const endpoints: ApiController = {
getSongList: navidromeApi.getSongList,
getTopSongs: subsonicApi.getTopSongList,
getUserList: navidromeApi.getUserList,
removeFromPlaylist: navidromeApi.removeFromPlaylist,
scrobble: subsonicApi.scrobble,
updatePlaylist: navidromeApi.updatePlaylist,
updateRating: subsonicApi.updateRating,
},
@@ -163,13 +179,14 @@ const endpoints: ApiController = {
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' });
@@ -239,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);
};
@@ -269,7 +294,12 @@ 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,
@@ -287,6 +317,8 @@ export const controller = {
getSongList,
getTopSongList,
getUserList,
removeFromPlaylist,
scrobble,
updatePlaylist,
updateRating,
};
+170 -6
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,
@@ -64,16 +70,26 @@ import {
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,
@@ -87,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,
@@ -314,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;
@@ -362,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;
@@ -536,6 +620,81 @@ const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> =>
};
};
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;
@@ -677,6 +836,7 @@ const normalizeSong = (
name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
releaseDate: null,
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
@@ -725,7 +885,7 @@ const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: numbe
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
releaseDate: item.PremiereDate?.split('T')[0] || null,
releaseYear: item.ProductionYear,
releaseYear: item.ProductionYear || null,
serverId: server.id,
serverType: ServerType.JELLYFIN,
size: null,
@@ -863,6 +1023,7 @@ const normalizePlaylist = (
// };
export const jellyfinApi = {
addToPlaylist,
authenticate,
createFavorite,
createPlaylist,
@@ -879,6 +1040,9 @@ export const jellyfinApi = {
getPlaylistList,
getPlaylistSongList,
getSongList,
getTopSongList,
removeFromPlaylist,
scrobble,
updatePlaylist,
};
+21
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[];
}
@@ -252,6 +271,7 @@ export type JFSong = {
MediaType: string;
Name: string;
ParentIndexNumber: number;
PlaylistItemId?: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
@@ -547,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',
+55
View File
@@ -41,6 +41,12 @@ import type {
NDUserListResponse,
NDUserListParams,
NDUser,
NDAddToPlaylist,
NDAddToPlaylistBody,
NDAddToPlaylistResponse,
NDRemoveFromPlaylistParams,
NDRemoveFromPlaylistResponse,
NDRemoveFromPlaylist,
} from '/@/renderer/api/navidrome.types';
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
import {
@@ -73,6 +79,8 @@ import {
sortOrderMap,
User,
LibraryItem,
AddToPlaylistArgs,
RemoveFromPlaylistArgs,
} from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
@@ -80,6 +88,8 @@ import { ServerListItem, ServerType } from '/@/renderer/types';
import { parseSearchParams } from '/@/renderer/utils';
import { subsonicApi } from '/@/renderer/api/subsonic.api';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const api = ky.create({
hooks: {
afterResponse: [
@@ -114,6 +124,7 @@ const api = ky.create({
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const authenticate = async (
@@ -270,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,
};
@@ -472,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;
@@ -501,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;
}
@@ -542,6 +594,7 @@ const normalizeSong = (
name: item.title,
path: item.path,
playCount: item.playCount,
playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(),
releaseYear: String(item.year),
serverId: server.id,
@@ -675,6 +728,7 @@ const normalizeUser = (item: NDUser): User => {
};
export const navidromeApi = {
addToPlaylist,
authenticate,
createPlaylist,
deletePlaylist,
@@ -689,6 +743,7 @@ export const navidromeApi = {
getSongDetail,
getSongList,
getUserList,
removeFromPlaylist,
updatePlaylist,
};
+20
View File
@@ -274,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;
+1
View File
@@ -99,6 +99,7 @@ const topSongList = (data: RawTopSongListResponse | undefined, server: ServerLis
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, ''));
+30 -6
View File
@@ -26,6 +26,7 @@ import type {
SSArtistInfo,
SSSong,
SSTopSongList,
SSScrobbleParams,
} from '/@/renderer/api/subsonic.types';
import {
AlbumArtistDetailArgs,
@@ -42,6 +43,8 @@ import {
QueueSong,
RatingArgs,
RatingResponse,
RawScrobbleResponse,
ScrobbleArgs,
ServerListItem,
ServerType,
TopSongListArgs,
@@ -49,6 +52,8 @@ import {
import { toast } from '/@/renderer/components/toast';
import { nanoid } from 'nanoid/non-secure';
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
const getCoverArtUrl = (args: {
baseUrl: string;
coverArtId: string;
@@ -90,6 +95,7 @@ const api = ky.create({
},
],
},
mode: IGNORE_CORS ? 'cors' : undefined,
});
const getDefaultParams = (server: ServerListItem | null) => {
@@ -319,7 +325,9 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
const { server, query, signal } = args;
const defaultParams = getDefaultParams(server);
for (const id of query.id) {
const itemIds = query.item.map((item) => item.id);
for (const id of itemIds) {
const searchParams: SSRatingParams = {
id,
rating: query.rating,
@@ -331,13 +339,9 @@ const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
searchParams: parseSearchParams(searchParams),
signal,
});
// .json<SSRatingResponse>();
}
return {
id: query.id,
rating: query.rating,
};
return null;
};
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
@@ -386,6 +390,25 @@ const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
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({
@@ -465,6 +488,7 @@ export const subsonicApi = {
getGenreList,
getMusicFolderList,
getTopSongList,
scrobble,
updateRating,
};
+6
View File
@@ -216,3 +216,9 @@ export type SSTopSongList = {
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};
+61 -3
View File
@@ -54,6 +54,16 @@ export enum LibraryItem {
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',
@@ -205,6 +215,7 @@ export type Song = {
name: string;
path: string | null;
playCount: number;
playlistItemId?: string;
releaseDate: string | null;
releaseYear: string | null;
serverId: string;
@@ -294,6 +305,7 @@ export type MusicFoldersResponse = MusicFolder[];
export type ListSortOrder = NDOrder | JFSortOrder;
type BaseEndpointArgs = {
_serverId?: string;
server: ServerListItem | null;
signal?: AbortSignal;
};
@@ -331,6 +343,7 @@ export enum AlbumListSort {
}
export type AlbumListQuery = {
artistIds?: string[];
jfParams?: {
albumArtistIds?: string;
artistIds?: string;
@@ -771,12 +784,41 @@ export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
// Rating
export type RawRatingResponse = RatingResponse | undefined;
export type RatingResponse = { id: string[]; rating: number };
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;
@@ -850,6 +892,7 @@ export type PlaylistListQuery = {
limit?: number;
ndParams?: {
owner_id?: string;
smart?: boolean;
};
searchTerm?: string;
sortBy: PlaylistListSort;
@@ -967,12 +1010,13 @@ export const userListSortMap: UserListSortMap = {
};
// Top Songs List
export type RawTopSongListResponse = SSTopSongList | undefined;
export type RawTopSongListResponse = SSTopSongList | JFSongList | undefined;
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
export type TopSongListQuery = {
artist: string;
artistId: string;
limit?: number;
};
@@ -986,3 +1030,17 @@ export type ArtistInfoQuery = {
};
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 {
+26 -50
View File
@@ -5,11 +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 { 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'>;
@@ -99,21 +103,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
}
`;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Add to queue',
play: Play.LAST,
},
{
label: 'Add to queue next',
play: Play.NEXT,
},
];
export const CardControls = ({
itemData,
itemType,
@@ -123,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();
@@ -137,6 +126,11 @@ export const CardControls = ({
});
};
const handleContextMenu = useHandleGeneralContextMenu(
itemType,
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
);
return (
<GridCardControlsContainer>
<BottomControls>
@@ -161,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>
+87 -16
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;
@@ -20,37 +19,109 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
min-width: ${({ minWidth }) => minWidth}px;
max-width: ${({ maxWidth }) => maxWidth}px;
background: var(--dropdown-menu-bg);
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 1.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 }}
/>
+20 -17
View File
@@ -6,7 +6,6 @@ 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;
@@ -22,6 +21,7 @@ type MenuDropdownProps = MantineMenuDropdownProps;
const StyledMenu = styled(MantineMenu)<MenuProps>``;
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
padding: 0.5rem;
font-family: var(--content-font-family);
`;
@@ -41,8 +41,8 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
left: 0;
width: 100%;
height: 100%;
background-color: var(--primary-color);
opacity: 0.2;
background-color: var(--dropdown-menu-bg-hover);
opacity: 0.5;
z-index: -1;
}
@@ -56,33 +56,37 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
background-color: var(--dropdown-menu-bg-hover);
}
& .mantine-Menu-itemIcon {
margin-right: 0.5rem;
}
& .mantine-Menu-itemLabel {
margin-right: 2rem;
margin-left: 1rem;
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
font-weight: 500;
font-size: 1em;
}
& .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) => {
@@ -94,7 +98,6 @@ export const DropdownMenu = ({ children, ...props }: MenuProps) => {
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
},
}}
transition="fade"
{...props}
>
{children}
@@ -111,7 +114,7 @@ const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) =>
<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>
@@ -199,10 +199,11 @@ const Title = ({ children }: TitleProps) => {
<Group position="apart">
{children}
{showPaginationButtons && (
<Group>
<Group spacing="sm">
<Button
compact
disabled={!pagination?.hasPreviousPage}
size="md"
variant="default"
onClick={handlePreviousPage}
>
@@ -210,6 +211,7 @@ const Title = ({ children }: TitleProps) => {
</Button>
<Button
compact
size="md"
variant="default"
onClick={handleNextPage}
>
@@ -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;
+2
View File
@@ -33,3 +33,5 @@ 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;
+15 -5
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;
@@ -66,6 +73,7 @@ export interface PageHeaderProps
const TitleWrapper = styled(motion.div)`
position: absolute;
display: flex;
width: 100%;
height: 100%;
`;
@@ -86,6 +94,7 @@ export const PageHeader = ({
}: PageHeaderProps) => {
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
const { windowBarStyle } = useWindowSettings();
const theme = useTheme();
return (
@@ -96,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="fade"
transitionProps={{ transition: 'fade' }}
{...props}
>
{children}
@@ -1,7 +1,7 @@
import { Group, Stack } from '@mantine/core';
import { Select } from '/@/renderer/components/select';
import { AnimatePresence, motion } from 'framer-motion';
import { RiAddLine, RiMore2Line } from 'react-icons/ri';
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';
@@ -87,8 +87,11 @@ export const QueryBuilder = ({
};
return (
<Stack ml={`${level * 10}px`}>
<Group>
<Stack
ml={`${level * 10}px`}
spacing="sm"
>
<Group spacing="sm">
<Select
data={FILTER_GROUP_OPTIONS_DATA}
maxWidth={175}
@@ -117,10 +120,18 @@ export const QueryBuilder = ({
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={handleAddRuleGroup}>Add rule group</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddFill />}
onClick={handleAddRuleGroup}
>
Add rule group
</DropdownMenu.Item>
{level > 0 && (
<DropdownMenu.Item onClick={handleDeleteRuleGroup}>
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
onClick={handleDeleteRuleGroup}
>
Remove rule group
</DropdownMenu.Item>
)}
@@ -129,12 +140,14 @@ export const QueryBuilder = ({
<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
@@ -91,7 +91,11 @@ const QueryValueInput = ({ onChange, type, ...props }: any) => {
case 'boolean':
return (
<Select
data={[]}
data={[
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]}
onChange={onChange}
{...props}
/>
);
@@ -178,14 +182,17 @@ export const QueryBuilderOption = ({
const ml = (level + 1) * 10;
return (
<Group ml={ml}>
<Group
ml={ml}
spacing="sm"
>
<Select
searchable
data={filters}
maxWidth={170}
size="sm"
value={field}
width="20%"
width="25%"
onChange={handleChangeField}
/>
<Select
@@ -195,7 +202,7 @@ export const QueryBuilderOption = ({
maxWidth={170}
size="sm"
value={operator}
width="20%"
width="25%"
onChange={handleChangeOperator}
/>
{field ? (
@@ -204,7 +211,7 @@ export const QueryBuilderOption = ({
maxWidth={170}
size="sm"
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
width="20%"
width="25%"
onChange={handleChangeValue}
/>
) : (
@@ -213,7 +220,7 @@ export const QueryBuilderOption = ({
defaultValue={value}
maxWidth={170}
size="sm"
width="20%"
width="25%"
onChange={handleChangeValue}
/>
)}
+20 -3
View File
@@ -1,7 +1,12 @@
/* 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';
type RatingProps = MantineRatingProps;
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
}
const StyledRating = styled(MantineRating)`
& .mantine-Rating-symbolBody {
@@ -11,6 +16,18 @@ const StyledRating = styled(MantineRating)`
}
`;
export const Rating = ({ ...props }: RatingProps) => {
return <StyledRating {...props} />;
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>
);
};
+26 -10
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<{ scrollBarOffset?: string }>`
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
height: 100%;
overflow-y: overlay !important;
&::-webkit-scrollbar-track {
margin-top: ${(props) => props.scrollBarOffset || '35px'};
margin-top: ${(props) =>
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
}
&::-webkit-scrollbar-thumb {
margin-top: ${(props) => props.scrollBarOffset || '35px'};
margin-top: ${(props) =>
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
}
`;
@@ -54,8 +58,11 @@ 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(
@@ -65,13 +72,19 @@ export const NativeScrollArea = forwardRef(
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);
@@ -110,16 +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="fade"
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="fade"
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;
+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 { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import { Skeleton } from '/@/renderer/components/skeleton';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
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;
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,153 +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>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
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
handleFavorite={controls.handleFavorite}
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,19 +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 { 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;
@@ -24,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;
@@ -59,6 +63,8 @@ const SecondaryButton = styled(_Button)`
`;
const GridCardControlsContainer = styled.div`
position: absolute;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
@@ -72,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;
`;
@@ -99,21 +94,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
}
`;
const PLAY_TYPES = [
{
label: 'Play',
play: Play.NOW,
},
{
label: 'Add to queue',
play: Play.LAST,
},
{
label: 'Add to queue next',
play: Play.NEXT,
},
];
export const GridCardControls = ({
itemData,
itemType,
@@ -125,7 +105,7 @@ export const GridCardControls = ({
itemData: any;
itemType: LibraryItem;
}) => {
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
e.preventDefault();
@@ -151,66 +131,47 @@ export const GridCardControls = ({
});
};
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
p={5}
sx={{ svg: { fill: 'white !important' } }}
variant="subtle"
onClick={handleFavorites}
>
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
{itemData?.userFavorite ? (
<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,15 +3,11 @@ 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,
@@ -27,9 +23,14 @@ 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}`}
@@ -43,24 +44,20 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
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 { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { Album, Artist, AlbumArtist, LibraryItem } 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,155 +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>[];
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
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
handleFavorite={controls.handleFavorite}
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>
);
};
@@ -1,4 +1,12 @@
import { useRef, useMemo, useCallback, forwardRef, Ref, useImperativeHandle } from 'react';
import {
useState,
useRef,
useMemo,
useCallback,
forwardRef,
Ref,
useImperativeHandle,
} from 'react';
import debounce from 'lodash/debounce';
import type { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
@@ -19,24 +27,14 @@ interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSi
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
itemData: any[];
itemGap: number;
itemSize: number;
itemType: LibraryItem;
loading?: boolean;
minimumBatchSize?: number;
route?: CardRoute;
setItemData: (data: any[]) => void;
}
const constrainWidth = (width: number) => {
if (width < 1920) {
return width;
}
return 1920;
};
export const VirtualInfiniteGrid = forwardRef(
(
{
@@ -45,8 +43,6 @@ export const VirtualInfiniteGrid = forwardRef(
itemSize,
itemType,
cardRows,
itemData,
setItemData,
route,
onScroll,
display,
@@ -64,18 +60,19 @@ export const VirtualInfiniteGrid = forwardRef(
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) => {
@@ -155,7 +152,7 @@ export const VirtualInfiniteGrid = forwardRef(
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap}
itemHeight={itemHeight + itemGap / 2}
itemHeight={itemHeight}
itemType={itemType}
itemWidth={itemSize}
refInstance={(list) => {
@@ -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,7 +43,7 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
$secondary
component={Link}
overflow="hidden"
size="sm"
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,
})}
@@ -22,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`
@@ -102,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) => (
@@ -120,7 +121,7 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
$secondary
component={Link}
overflow="hidden"
size="sm"
size="md"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
@@ -6,11 +6,7 @@ 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,
useSetQueueFavorite,
} from '/@/renderer/store';
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
const useCreateFavorite = () => {
const server = useCurrentServer();
@@ -50,9 +46,6 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
const createMutation = useCreateFavorite();
const deleteMutation = useDeleteFavorite();
// Since the queue is using client-side state, we need to update it manually
const setFavorite = useSetQueueFavorite();
const handleToggleFavorite = () => {
const newFavoriteValue = !value;
@@ -66,10 +59,6 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
},
{
onSuccess: () => {
if (data.itemType === LibraryItem.SONG) {
setFavorite([data.id], newFavoriteValue);
}
node.setData({ ...data, userFavorite: newFavoriteValue });
},
},
@@ -84,10 +73,6 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
},
{
onSuccess: () => {
if (data.itemType === LibraryItem.SONG) {
setFavorite([data.id], newFavoriteValue);
}
node.setData({ ...data, userFavorite: newFavoriteValue });
},
},
@@ -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 || '—'}
@@ -1,13 +1,57 @@
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 });
},
},
);
};
export const RatingCell = ({ value }: ICellRendererParams) => {
return (
<CellContainer position="center">
<Rating
size="xs"
value={value}
value={value?.userRating}
onChange={handleUpdateRating}
onClick={handleClearRating}
/>
</CellContainer>
);
@@ -77,9 +77,9 @@ export const GenericTableHeader = (
return (
<TextHeaderWrapper
fw="500"
overflow="hidden"
position={position}
weight={500}
>
{children || displayName}
</TextHeaderWrapper>
@@ -1,12 +1,15 @@
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: '-63px 0px 0px 0px',
margin: windowBarStyle === Platform.WEB ? '-68px 0px 0px 0px' : '-98px 0px 0px 0px',
});
const tableInView = useInView(tableContainerRef, {
@@ -18,13 +21,19 @@ export const useFixedTableHeader = () => {
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]);
}, [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;
}
}
}
},
});
};
@@ -254,6 +254,7 @@ const tableColumns: { [key: string]: ColDef } = {
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
initialWidth: 500,
minWidth: 150,
valueGetter: (params: ValueGetterParams) =>
params.data
? {
@@ -263,7 +264,7 @@ 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,
@@ -292,7 +293,7 @@ const tableColumns: { [key: string]: ColDef } = {
width: 50,
},
userRating: {
cellClass: (params) => (params.value ? 'visible ag-cell-rating' : 'ag-cell-rating'),
cellClass: (params) => (params.value?.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating'),
cellRenderer: RatingCell,
colId: TableColumn.USER_RATING,
field: 'userRating',
@@ -300,7 +301,7 @@ const tableColumns: { [key: string]: ColDef } = {
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.userRating : undefined),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95,
},
};
@@ -427,6 +428,7 @@ export const VirtualTable = forwardRef(
ref={mergedRef}
animateRows
maintainColumnOrder
suppressAsyncEvents
suppressContextMenu
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
@@ -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 },
@@ -168,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>
@@ -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
@@ -41,9 +41,13 @@ const RouteErrorBoundary = () => {
</Group>
<Divider my={5} />
<Text size="sm">{error?.message}</Text>
<Group grow>
<Group
grow
spacing="sm"
>
<Button
leftIcon={<RiHome4Line />}
size="md"
sx={{ flex: 0.5 }}
variant="default"
onClick={handleHome}
@@ -51,6 +55,7 @@ const RouteErrorBoundary = () => {
Go home
</Button>
<Button
size="md"
variant="filled"
onClick={handleReload}
>
@@ -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,44 +1,47 @@
import { MutableRefObject, useCallback, useMemo } from 'react';
import {
Button,
DropdownMenu,
getColumnDefs,
GridCarousel,
Text,
TextTitle,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } 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 { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
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 { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { Play } from '/@/renderer/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import {
PlayButton,
PLAY_TYPES,
useCreateFavorite,
useDeleteFavorite,
} from '/@/renderer/features/shared';
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, 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 5rem;
overflow: hidden;
@@ -61,13 +64,82 @@ 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,
},
{
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,
@@ -126,8 +198,8 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
},
title: (
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
More from this artist
</TextTitle>
@@ -182,6 +254,11 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM,
ALBUM_CONTEXT_MENU_ITEMS,
);
return (
<ContentContainer>
<Box component="section">
@@ -189,7 +266,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
ref={showGenres ? null : intersectRef}
className="test"
py="1rem"
spacing="lg"
spacing="md"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
@@ -208,28 +285,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
<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>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
</Group>
</Group>
</Box>
@@ -239,16 +304,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
component="section"
py="1rem"
>
<Group>
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius="md"
size="sm"
radius={0}
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUMS}?genre=${genre.id}`, { albumId })}
variant="default"
variant="outline"
>
{genre.name}
</Button>
@@ -268,9 +333,29 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
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}
rowData={detailQuery.data?.songs}
rowHeight={60}
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}
@@ -2,10 +2,10 @@ 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 { LibraryItem } from '/@/renderer/api/types';
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 { formatDurationString } from '/@/renderer/utils';
@@ -38,6 +38,34 @@ 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
@@ -47,16 +75,28 @@ export const AlbumDetailHeader = forwardRef(
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_ALBUM_ARTISTS_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>
@@ -10,7 +10,7 @@ import {
import { AppRoute } from '/@/renderer/router/routes';
import { ListDisplayType, CardRow } from '/@/renderer/types';
import AutoSizer from 'react-virtualized-auto-sizer';
import { MutableRefObject, useCallback, useMemo, useState } from 'react';
import { MutableRefObject, useCallback, useMemo } from 'react';
import { ListOnScrollProps } from 'react-window';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
@@ -19,13 +19,9 @@ import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/a
import { useQueryClient } from '@tanstack/react-query';
import {
useCurrentServer,
useSetAlbumStore,
useAlbumListStore,
useAlbumTablePagination,
useSetAlbumTable,
useSetAlbumTablePagination,
useAlbumListItemData,
AlbumListFilter,
useListStoreActions,
useAlbumListFilter,
} from '/@/renderer/store';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import {
@@ -43,40 +39,26 @@ import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/cont
import { generatePath, useNavigate } from 'react-router';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
interface AlbumListContentProps {
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListContent = ({
customFilters,
itemCount,
gridRef,
tableRef,
}: AlbumListContentProps) => {
export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const server = useCurrentServer();
const page = useAlbumListStore();
const setPage = useSetAlbumStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const { id, pageKey } = useAlbumListContext();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setTable, setTablePagination, setGrid } = useListStoreActions();
const { table, grid, display } = useAlbumListStore({ id, key: pageKey });
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
const { itemData, setItemData } = useAlbumListItemData();
const [localItemData, setLocalItemData] = useState<any[]>([]);
const pagination = useAlbumTablePagination();
const setPagination = useSetAlbumTablePagination();
const setTable = useSetAlbumTable();
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
const columnDefs: ColDef[] = useMemo(
() => getColumnDefs(page.table.columns),
[page.table.columns],
);
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
const onTableReady = useCallback(
(params: GridReadyEvent) => {
@@ -88,15 +70,12 @@ export const AlbumListContent = ({
const query: AlbumListQuery = {
limit,
startIndex,
...page.filter,
...customFilters,
...filter,
jfParams: {
...page.filter.jfParams,
...customFilters?.jfParams,
...filter.jfParams,
},
ndParams: {
...page.filter.ndParams,
...customFilters?.ndParams,
...filter.ndParams,
},
};
@@ -114,34 +93,43 @@ export const AlbumListContent = ({
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
params.api.setDatasource(dataSource);
if (!customFilters) {
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
}
},
[customFilters, page.filter, page.table.scrollOffset, queryClient, server],
[filter, queryClient, server],
);
const onTablePaginationChanged = useCallback(
(event: PaginationChangedEvent) => {
if (!isPaginationEnabled || !event.api) return;
// Scroll to top of page on pagination change
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
try {
// Scroll to top of page on pagination change
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
} catch (err) {
console.log(err);
}
setPagination({
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
setTablePagination({
data: {
itemsPerPage: event.api.paginationGetPageSize(),
totalItems: event.api.paginationGetRowCount(),
totalPages: event.api.paginationGetTotalPages() + 1,
},
key: pageKey,
});
},
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
[
isPaginationEnabled,
setTablePagination,
pageKey,
table.pagination.currentPage,
table.pagination.itemsPerPage,
],
);
const handleTableColumnChange = useCallback(() => {
@@ -150,7 +138,7 @@ export const AlbumListContent = ({
if (!columnsOrder) return;
const columnsInSettings = page.table.columns;
const columnsInSettings = table.columns;
const updatedColumns = [];
for (const column of columnsOrder) {
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
@@ -158,22 +146,21 @@ export const AlbumListContent = ({
if (columnInSettings) {
updatedColumns.push({
...columnInSettings,
...(!page.table.autoFit && {
...(!table.autoFit && {
width: column.getColDef().width,
}),
});
}
}
setTable({ columns: updatedColumns });
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
setTable({ data: { columns: updatedColumns }, key: pageKey });
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
const handleTableScroll = (e: BodyScrollEvent) => {
if (customFilters) return;
const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0));
setTable({ scrollOffset });
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
setTable({ data: { scrollOffset }, key: pageKey });
};
const fetch = useCallback(
@@ -181,15 +168,12 @@ export const AlbumListContent = ({
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...page.filter,
...customFilters,
...filter,
jfParams: {
...page.filter.jfParams,
...customFilters?.jfParams,
...filter.jfParams,
},
ndParams: {
...page.filter.ndParams,
...customFilters?.ndParams,
...filter.ndParams,
},
};
@@ -205,29 +189,20 @@ export const AlbumListContent = ({
return api.normalize.albumList(albums, server);
},
[customFilters, page.filter, queryClient, server],
[filter, queryClient, server],
);
const handleGridScroll = useCallback(
(e: ListOnScrollProps) => {
if (customFilters) return;
setPage({
list: {
...page,
grid: {
...page.grid,
scrollOffset: e.scrollOffset,
},
},
});
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
},
[customFilters, page, setPage],
[pageKey, setGrid],
);
const cardRows = useMemo(() => {
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
switch (page.filter.sortBy) {
switch (filter.sortBy) {
case AlbumListSort.ALBUM_ARTIST:
rows.push(ALBUM_CARD_ROWS.albumArtists);
rows.push(ALBUM_CARD_ROWS.releaseYear);
@@ -285,7 +260,7 @@ export const AlbumListContent = ({
}
return rows;
}, [page.filter.sortBy]);
}, [filter.sortBy]);
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
@@ -322,32 +297,30 @@ export const AlbumListContent = ({
return (
<>
<VirtualGridAutoSizerContainer>
{page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? (
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
<AutoSizer>
{({ height, width }) => (
<>
<VirtualInfiniteGrid
key={`album-list-${server?.id}-${page.display}`}
key={`album-list-${server?.id}-${display}`}
ref={gridRef}
cardRows={cardRows}
display={page.display || ListDisplayType.CARD}
display={display || ListDisplayType.CARD}
fetchFn={fetch}
handleFavorite={handleFavorite}
handlePlayQueueAdd={handlePlayQueueAdd}
height={height}
initialScrollOffset={customFilters ? 0 : page?.grid.scrollOffset || 0}
initialScrollOffset={grid?.scrollOffset || 0}
itemCount={itemCount || 0}
itemData={customFilters ? localItemData : itemData}
itemGap={20}
itemSize={150 + page.grid?.size}
itemSize={grid?.itemsPerRow || 5}
itemType={LibraryItem.ALBUM}
loading={!itemCount}
loading={itemCount === undefined || itemCount === null}
minimumBatchSize={40}
route={{
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
setItemData={customFilters ? setLocalItemData : setItemData}
width={width}
onScroll={handleGridScroll}
/>
@@ -358,20 +331,20 @@ export const AlbumListContent = ({
<VirtualTable
// https://github.com/ag-grid/ag-grid/issues/5284
// Key is used to force remount of table when display, rowHeight, or server changes
key={`table-${page.display}-${page.table.rowHeight}-${server?.id}`}
key={`table-${display}-${table.rowHeight}-${server?.id}`}
ref={tableRef}
alwaysShowHorizontalScroll
suppressRowDrag
autoFitColumns={page.table.autoFit}
autoFitColumns={table.autoFit}
blockLoadDebounceMillis={200}
columnDefs={columnDefs}
getRowId={(data) => data.data.id}
infiniteInitialRowCount={itemCount || 1}
infiniteInitialRowCount={itemCount || 100}
pagination={isPaginationEnabled}
paginationAutoPageSize={isPaginationEnabled}
paginationPageSize={page.table.pagination.itemsPerPage || 100}
paginationPageSize={table.pagination.itemsPerPage || 100}
rowBuffer={20}
rowHeight={page.table.rowHeight || 40}
rowHeight={table.rowHeight || 40}
rowModelType="infinite"
onBodyScrollEnd={handleTableScroll}
onCellContextMenu={handleContextMenu}
@@ -389,10 +362,11 @@ export const AlbumListContent = ({
initial={false}
mode="wait"
>
{page.display === ListDisplayType.TABLE_PAGINATED && (
{display === ListDisplayType.TABLE_PAGINATED && (
<TablePagination
pagination={pagination}
setPagination={setPagination}
pageKey={pageKey}
pagination={table.pagination}
setPagination={setTablePagination}
tableRef={tableRef}
/>
)}
@@ -0,0 +1,617 @@
import { MutableRefObject, useCallback, MouseEvent, ChangeEvent, useMemo } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import {
RiSortAsc,
RiSortDesc,
RiFolder2Line,
RiMoreFill,
RiAddBoxFill,
RiPlayFill,
RiAddCircleFill,
RiRefreshLine,
RiSettings3Fill,
RiFilterFill,
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListQuery, AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
ALBUM_TABLE_COLUMNS,
Button,
DropdownMenu,
MultiSelect,
Slider,
Switch,
Text,
VirtualInfiniteGridRef,
} from '/@/renderer/components';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useAlbumListStore,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { ServerType, Play, ListDisplayType, TableColumn } from '/@/renderer/types';
import { useMusicFolders } from '/@/renderer/features/shared';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{
defaultOrder: SortOrder.DESC,
name: 'Community Rating',
value: AlbumListSort.COMMUNITY_RATING,
},
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
interface AlbumListHeaderFiltersProps {
customFilters?: Partial<AlbumListFilter>;
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListHeaderFilters = ({
customFilters,
gridRef,
tableRef,
itemCount,
}: AlbumListHeaderFiltersProps) => {
const queryClient = useQueryClient();
const { id, pageKey } = useAlbumListContext();
const server = useCurrentServer();
const { setFilter, setTablePagination, setTable, setGrid, setDisplayType, setTableColumns } =
useListStoreActions();
const { display, filter, table, grid } = useAlbumListStore({ id, key: pageKey });
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders();
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filter.sortOrder)?.name || 'Unknown';
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
},
...customFilters,
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albums = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
return api.normalize.albumList(albums, server);
},
[customFilters, queryClient, server],
);
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
const startIndex = params.startRow;
const query: AlbumListQuery = {
limit,
startIndex,
...filters,
...customFilters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumsRes = await queryClient.fetchQuery(
queryKey,
async ({ signal }) =>
api.controller.getAlbumList({
query,
server,
signal,
}),
{ cacheTime: 1000 * 60 * 1 },
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
tableRef.current?.api.setDatasource(dataSource);
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
} else {
gridRef.current?.scrollTo(0);
gridRef.current?.resetLoadMoreItemsCache();
// Refetching within the virtualized grid may be inconsistent due to it refetching
// using an outdated set of filters. To avoid this, we fetch using the updated filters
// and then set the grid's data here.
const data = await fetch(0, 200, filters);
if (!data?.items) return;
gridRef.current?.setItemData(data.items);
}
},
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
);
const handleOpenFiltersModal = () => {
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
/>
) : (
<JellyfinAlbumFilters
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
id={id}
pageKey={pageKey}
/>
)}
</>
),
title: 'Album Filters',
});
};
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
data: {
sortBy: e.currentTarget.value as AlbumListSort,
sortOrder: sortOrder || SortOrder.ASC,
},
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
let updatedFilters = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
data: { musicFolderId: undefined },
key: 'album',
}) as AlbumListFilter;
} else {
updatedFilters = setFilter({
data: { musicFolderId: e.currentTarget.value },
key: 'album',
}) as AlbumListFilter;
}
handleFilterChange(updatedFilters);
},
[handleFilterChange, filter.musicFolderId, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
data: { sortOrder: newSortOrder },
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, [filter.sortOrder, handleFilterChange, setFilter]);
const handlePlayQueueAdd = usePlayQueueAdd();
const handlePlay = async (play: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filter,
...customFilters,
jfParams: {
...filter.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filter.ndParams,
...customFilters?.ndParams,
},
};
const queryKey = queryKeys.albums.list(server?.id || '', query);
const albumListRes = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
queryKey,
});
const albumIds =
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
handlePlayQueueAdd?.({
byItemType: {
id: albumIds,
type: LibraryItem.ALBUM,
},
play,
});
};
const handleItemSize = (e: number) => {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
setTable({ data: { rowHeight: e }, key: 'album' });
} else {
setGrid({ data: { itemsPerRow: e }, key: 'album' });
}
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: 'album' });
},
[setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTableColumns({
data: [],
key: 'album',
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTableColumns({ data: [...existingColumns, newColumn], key: 'album' });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTableColumns({ data: newColumns, key: 'album' });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: 'album' });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const isFilterApplied = useMemo(() => {
const isNavidromeFilterApplied =
server?.type === ServerType.NAVIDROME &&
filter.ndParams &&
Object.values(filter.ndParams).some((value) => value !== undefined);
const isJellyfinFilterApplied =
server?.type === ServerType.JELLYFIN &&
filter.jfParams &&
Object.values(filter.jfParams).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter.jfParams, filter.ndParams, server?.type]);
return (
<Flex justify="space-between">
<Group
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw={600}
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
key={`filter-${f.name}`}
$isActive={f.value === filter.sortBy}
value={f.value}
onClick={handleSetSortBy}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw={600}
size="md"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isSm ? (
sortOrderLabel
) : (
<>
{filter.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw={600}
size="md"
variant="subtle"
>
{cq.isSm ? 'Folder' : <RiFolder2Line size="1.3rem" />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filter.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
Play
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
Add to queue next
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
onClick={handleRefresh}
>
Refresh
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group
noWrap
spacing="sm"
>
<Button
compact
size="md"
sx={{ svg: { fill: isFilterApplied ? 'var(--primary-color) !important' : undefined } }}
tooltip={{ label: 'Filters' }}
variant="subtle"
onClick={handleOpenFiltersModal}
>
<RiFilterFill size="1.3rem" />
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
compact
size="md"
tooltip={{ label: 'Configure' }}
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER
? 'Items per row'
: 'Item size'}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={
display === ListDisplayType.CARD || display === ListDisplayType.POSTER
? grid?.itemsPerRow || 0
: table.rowHeight
}
label={null}
max={14}
min={2}
onChange={debouncedHandleItemSize}
/>
</DropdownMenu.Item>
{(display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={ALBUM_TABLE_COLUMNS}
defaultValue={table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Flex>
);
};
@@ -1,101 +1,29 @@
import type { ChangeEvent, MouseEvent, MutableRefObject } from 'react';
import type { ChangeEvent, MutableRefObject } from 'react';
import { useCallback } from 'react';
import { IDatasource } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import { openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import {
RiArrowDownSLine,
RiFilter3Line,
RiFolder2Line,
RiMoreFill,
RiSortAsc,
RiSortDesc,
} from 'react-icons/ri';
import styled from 'styled-components';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import {
ALBUM_TABLE_COLUMNS,
Badge,
Button,
DropdownMenu,
MultiSelect,
PageHeader,
SearchInput,
Slider,
SpinnerIcon,
Switch,
Text,
TextTitle,
VirtualInfiniteGridRef,
} from '/@/renderer/components';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { useMusicFolders } from '/@/renderer/features/shared';
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumListFilter,
useAlbumListFilter,
useAlbumListStore,
useCurrentServer,
useSetAlbumFilters,
useSetAlbumStore,
useSetAlbumTable,
useSetAlbumTablePagination,
useListStoreActions,
} from '/@/renderer/store';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import { ListDisplayType, Play } from '/@/renderer/types';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
import { usePlayQueueAdd } from '/@/renderer/features/player';
const FILTERS = {
jellyfin: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{
defaultOrder: SortOrder.DESC,
name: 'Community Rating',
value: AlbumListSort.COMMUNITY_RATING,
},
{ defaultOrder: SortOrder.DESC, name: 'Critic Rating', value: AlbumListSort.CRITIC_RATING },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Release Date', value: AlbumListSort.RELEASE_DATE },
],
navidrome: [
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: AlbumListSort.ALBUM_ARTIST },
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: AlbumListSort.ARTIST },
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: AlbumListSort.DURATION },
{ defaultOrder: SortOrder.DESC, name: 'Most Played', value: AlbumListSort.PLAY_COUNT },
{ defaultOrder: SortOrder.ASC, name: 'Name', value: AlbumListSort.NAME },
{ defaultOrder: SortOrder.ASC, name: 'Random', value: AlbumListSort.RANDOM },
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: AlbumListSort.RATING },
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
{ defaultOrder: SortOrder.DESC, name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: AlbumListSort.FAVORITED },
{ defaultOrder: SortOrder.DESC, name: 'Year', value: AlbumListSort.YEAR },
],
};
const ORDER = [
{ name: 'Ascending', value: SortOrder.ASC },
{ name: 'Descending', value: SortOrder.DESC },
];
const HeaderItems = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
`;
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
interface AlbumListHeaderProps {
customFilters?: Partial<AlbumListFilter>;
@@ -114,34 +42,11 @@ export const AlbumListHeader = ({
}: AlbumListHeaderProps) => {
const queryClient = useQueryClient();
const server = useCurrentServer();
const setPage = useSetAlbumStore();
const setFilter = useSetAlbumFilters();
const page = useAlbumListStore();
const filters = page.filter;
const { setFilter, setTablePagination } = useListStoreActions();
const cq = useContainerQuery();
const musicFoldersQuery = useMusicFolders();
const setPagination = useSetAlbumTablePagination();
const setTable = useSetAlbumTable();
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
'Unknown';
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
const handleItemSize = (e: number) => {
if (
page.display === ListDisplayType.TABLE ||
page.display === ListDisplayType.TABLE_PAGINATED
) {
setTable({ rowHeight: e });
} else {
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
}
};
const { id, pageKey } = useAlbumListContext();
const { display } = useAlbumListStore({ id, key: pageKey });
const filter = useAlbumListFilter({ id, key: pageKey });
const fetch = useCallback(
async (skip: number, take: number, filters: AlbumListFilter) => {
@@ -180,10 +85,7 @@ export const AlbumListHeader = ({
const handleFilterChange = useCallback(
async (filters: AlbumListFilter) => {
if (
page.display === ListDisplayType.TABLE ||
page.display === ListDisplayType.TABLE_PAGINATED
) {
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
const dataSource: IDatasource = {
getRows: async (params) => {
const limit = params.endRow - params.startRow;
@@ -218,7 +120,7 @@ export const AlbumListHeader = ({
);
const albums = api.normalize.albumList(albumsRes, server);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined);
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
},
rowCount: undefined,
};
@@ -226,8 +128,8 @@ export const AlbumListHeader = ({
tableRef.current?.api.purgeInfiniteCache();
tableRef.current?.api.ensureIndexVisible(0, 'top');
if (page.display === ListDisplayType.TABLE_PAGINATED) {
setPagination({ currentPage: 0 });
if (display === ListDisplayType.TABLE_PAGINATED) {
setTablePagination({ data: { currentPage: 0 }, key: 'album' });
}
} else {
gridRef.current?.scrollTo(0);
@@ -242,138 +144,32 @@ export const AlbumListHeader = ({
gridRef.current?.setItemData(data.items);
}
},
[page.display, tableRef, customFilters, server, queryClient, setPagination, gridRef, fetch],
);
const handleOpenFiltersModal = () => {
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
/>
) : (
<JellyfinAlbumFilters
disableArtistFilter={!!customFilters}
handleFilterChange={handleFilterChange}
/>
)}
</>
),
title: 'Album Filters',
});
};
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
handleFilterChange(filters);
}, [filters, handleFilterChange, queryClient, server?.id]);
const handleSetSortBy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value || !server?.type) return;
const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find(
(f) => f.value === e.currentTarget.value,
)?.defaultOrder;
const updatedFilters = setFilter({
sortBy: e.currentTarget.value as AlbumListSort,
sortOrder: sortOrder || SortOrder.ASC,
});
handleFilterChange(updatedFilters);
},
[handleFilterChange, server?.type, setFilter],
);
const handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
let updatedFilters = null;
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
updatedFilters = setFilter({ musicFolderId: undefined });
} else {
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
}
handleFilterChange(updatedFilters);
},
[handleFilterChange, page.filter.musicFolderId, setFilter],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({ sortOrder: newSortOrder });
handleFilterChange(updatedFilters);
}, [filters.sortOrder, handleFilterChange, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
},
[page, setPage],
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const previousSearchTerm = page.filter.searchTerm;
const previousSearchTerm = filter.searchTerm;
const searchTerm = e.target.value === '' ? undefined : e.target.value;
const updatedFilters = setFilter({ searchTerm });
const updatedFilters = setFilter({ data: { searchTerm }, key: 'album' }) as AlbumListFilter;
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
}, 500);
const handleTableColumns = (values: TableColumn[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
return setTable({
columns: [],
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
setTable({ columns: [...existingColumns, newColumn] });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ columns: newColumns });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
if (e.currentTarget.checked) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = async (play: Play) => {
if (!itemCount || itemCount === 0) return;
const query = {
startIndex: 0,
...filters,
...filter,
...customFilters,
jfParams: {
...filters.jfParams,
...filter.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...filter.ndParams,
...customFilters?.ndParams,
},
};
@@ -397,219 +193,39 @@ export const AlbumListHeader = ({
};
return (
<PageHeader p="1rem">
<HeaderItems ref={cq.ref}>
<Stack
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Flex
align="center"
gap="md"
justify="center"
justify="space-between"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
px={0}
rightIcon={<RiArrowDownSLine size={15} />}
size="xl"
variant="subtle"
>
<Group noWrap>
<TextTitle
fw="bold"
maw="20vw"
order={3}
overflow="hidden"
>
{title || 'Albums'}
</TextTitle>
<Badge
radius="xl"
size="lg"
>
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
</Badge>
</Group>
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>Display type</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.CARD}
value={ListDisplayType.CARD}
onClick={handleSetViewType}
>
Card
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.POSTER}
value={ListDisplayType.POSTER}
onClick={handleSetViewType}
>
Poster
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE}
value={ListDisplayType.TABLE}
onClick={handleSetViewType}
>
Table
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>Item size</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={
page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
? page.grid.size
: page.table.rowHeight
}
label={null}
max={100}
min={25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
{(page.display === ListDisplayType.TABLE ||
page.display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={ALBUM_TABLE_COLUMNS}
defaultValue={page.table?.columns.map((column) => column.column)}
width={300}
onChange={handleTableColumns}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{sortByLabel}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
key={`filter-${filter.name}`}
$isActive={filter.value === filters.sortBy}
value={filter.value}
onClick={handleSetSortBy}
>
{filter.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
fw="600"
variant="subtle"
onClick={handleToggleSortOrder}
>
{cq.isMd ? (
sortOrderLabel
) : (
<>
{filters.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</>
)}
</Button>
{server?.type === ServerType.JELLYFIN && (
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filters.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<Button
compact
fw="600"
variant="subtle"
onClick={handleOpenFiltersModal}
>
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item onClick={() => handlePlay(Play.NOW)}>Play</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => handlePlay(Play.LAST)}>
Add to queue
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => handlePlay(Play.NEXT)}>
Add to queue next
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item onClick={handleRefresh}>Refresh</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
{itemCount}
</LibraryHeaderBar.Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
onChange={handleSearch}
/>
</Group>
</Flex>
<Flex gap="md">
<SearchInput
defaultValue={page.filter.searchTerm}
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
onChange={handleSearch}
/>
</Flex>
</HeaderItems>
</PageHeader>
</PageHeader>
<FilterBar>
<AlbumListHeaderFilters
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>
</Stack>
);
};
@@ -1,24 +1,27 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { AlbumListFilter, useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useDebouncedValue } from '@mantine/hooks';
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
interface JellyfinAlbumFiltersProps {
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
}
export const JellyfinAlbumFilters = ({
disableArtistFilter,
handleFilterChange,
pageKey,
id,
}: JellyfinAlbumFiltersProps) => {
const { filter } = useAlbumListStore();
const setFilters = useSetAlbumFilters();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
const genreListQuery = useGenreList(null);
@@ -39,61 +42,75 @@ export const JellyfinAlbumFilters = ({
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
jfParams: { ...filter.jfParams, isFavorite: e.currentTarget.checked ? true : undefined },
});
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
includeItemTypes: 'Audio',
isFavorite: e.currentTarget.checked ? true : undefined,
},
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.jfParams?.isFavorite,
},
];
const handleMinYearFilter = debounce((e: number | undefined) => {
if (e && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
minYear: e,
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
minYear: e === '' ? undefined : (e as number),
},
},
});
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const handleMaxYearFilter = debounce((e: number | undefined) => {
if (e && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
maxYear: e,
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
maxYear: e === '' ? undefined : (e as number),
},
},
});
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.join(',');
const updatedFilters = setFilters({
jfParams: {
...filter.jfParams,
genreIds: genreFilterString,
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
genreIds: genreFilterString,
},
},
});
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const [debouncedSearchTerm] = useDebouncedValue(albumArtistSearchTerm, 200);
const albumArtistListQuery = useAlbumArtistList(
{
limit: 300,
searchTerm: debouncedSearchTerm,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
{
enabled: debouncedSearchTerm ? debouncedSearchTerm !== '' : false,
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
);
@@ -106,6 +123,20 @@ export const JellyfinAlbumFilters = ({
}));
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
data: {
jfParams: {
...filter.jfParams,
albumArtistIds: albumArtistFilterString,
},
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
@@ -124,22 +155,22 @@ export const JellyfinAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.jfParams?.minYear}
hideControls={false}
label="From year"
max={2300}
min={1700}
required={!!filter.jfParams?.maxYear}
value={filter.jfParams?.minYear}
onChange={handleMinYearFilter}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter.jfParams?.maxYear}
hideControls={false}
label="To year"
max={2300}
min={1700}
required={!!filter.jfParams?.minYear}
value={filter.jfParams?.maxYear}
onChange={handleMaxYearFilter}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
<Group grow>
@@ -158,12 +189,14 @@ export const JellyfinAlbumFilters = ({
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter.jfParams?.albumArtistIds?.split(',')}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
@@ -1,8 +1,7 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import { NumberInput, Switch, Text, Select, SpinnerIcon } from '/@/renderer/components';
import { AlbumListFilter, useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
import { useDebouncedValue } from '@mantine/hooks';
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
@@ -11,14 +10,18 @@ import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
interface NavidromeAlbumFiltersProps {
disableArtistFilter?: boolean;
handleFilterChange: (filters: AlbumListFilter) => void;
id?: string;
pageKey: string;
}
export const NavidromeAlbumFilters = ({
handleFilterChange,
disableArtistFilter,
pageKey,
id,
}: NavidromeAlbumFiltersProps) => {
const { filter } = useAlbumListStore();
const setFilters = useSetAlbumFilters();
const filter = useAlbumListFilter({ id, key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList(null);
@@ -31,12 +34,15 @@ export const NavidromeAlbumFilters = ({
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilters({
ndParams: {
...filter.ndParams,
genre_id: e || undefined,
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
genre_id: e || undefined,
},
},
});
key: 'album',
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 250);
@@ -44,9 +50,15 @@ export const NavidromeAlbumFilters = ({
{
label: 'Is rated',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
ndParams: { ...filter.ndParams, has_rating: e.currentTarget.checked ? true : undefined },
});
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
has_rating: e.currentTarget.checked ? true : undefined,
},
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.has_rating,
@@ -54,9 +66,12 @@ export const NavidromeAlbumFilters = ({
{
label: 'Is favorited',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
});
const updatedFilters = setFilter({
data: {
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.starred,
@@ -64,9 +79,15 @@ export const NavidromeAlbumFilters = ({
{
label: 'Is compilation',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
ndParams: { ...filter.ndParams, compilation: e.currentTarget.checked ? true : undefined },
});
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
compilation: e.currentTarget.checked ? true : undefined,
},
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.compilation,
@@ -74,41 +95,46 @@ export const NavidromeAlbumFilters = ({
{
label: 'Is recently played',
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilters({
ndParams: {
...filter.ndParams,
recently_played: e.currentTarget.checked ? true : undefined,
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
recently_played: e.currentTarget.checked ? true : undefined,
},
},
});
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
},
value: filter.ndParams?.recently_played,
},
];
const handleYearFilter = debounce((e: number | undefined) => {
const updatedFilters = setFilters({
ndParams: {
...filter.ndParams,
year: e,
const handleYearFilter = debounce((e: number | string) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
year: e === '' ? undefined : (e as number),
},
},
});
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
}, 500);
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
const [debouncedSearchTerm] = useDebouncedValue(albumArtistSearchTerm, 200);
const albumArtistListQuery = useAlbumArtistList(
{
limit: 300,
searchTerm: debouncedSearchTerm,
// searchTerm: debouncedSearchTerm,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
{
enabled: debouncedSearchTerm ? debouncedSearchTerm !== '' : false,
cacheTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
);
@@ -121,6 +147,19 @@ export const NavidromeAlbumFilters = ({
}));
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string | null) => {
const updatedFilters = setFilter({
data: {
ndParams: {
...filter.ndParams,
artist_id: e || undefined,
},
},
key: pageKey,
}) as AlbumListFilter;
handleFilterChange(updatedFilters);
};
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
@@ -138,12 +177,12 @@ export const NavidromeAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.ndParams?.year}
hideControls={false}
label="Year"
max={5000}
min={0}
value={filter.ndParams?.year}
onChange={handleYearFilter}
onChange={(e) => handleYearFilter(e)}
/>
<Select
clearable
@@ -159,12 +198,14 @@ export const NavidromeAlbumFilters = ({
clearable
searchable
data={selectableAlbumArtists}
defaultValue={filter.ndParams?.artist_id}
disabled={disableArtistFilter}
label="Artist"
limit={300}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchValue={albumArtistSearchTerm}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
@@ -0,0 +1,11 @@
import { createContext, useContext } from 'react';
import { ListKey } from '/@/renderer/store';
export const AlbumListContext = createContext<{ id?: string; pageKey: ListKey }>({
pageKey: 'album',
});
export const useAlbumListContext = () => {
const ctxValue = useContext(AlbumListContext);
return ctxValue;
};
@@ -1,57 +1,38 @@
import { VirtualGridContainer, VirtualInfiniteGridRef } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components';
import { AnimatedPage } from '/@/renderer/features/shared';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { useRef } from 'react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { useAlbumListFilters, useCurrentServer } from '/@/renderer/store';
import { useSearchParams } from 'react-router-dom';
import { AlbumListQuery, ServerType } from '/@/renderer/api/types';
import { generatePageKey, useAlbumListFilter, useCurrentServer } from '/@/renderer/store';
import { useParams, useSearchParams } from 'react-router-dom';
import { AlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
const AlbumListRoute = () => {
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
const tableRef = useRef<AgGridReactType | null>(null);
const filters = useAlbumListFilters();
const server = useCurrentServer();
const [searchParams] = useSearchParams();
const { albumArtistId } = useParams();
const customFilters: Partial<AlbumListQuery> | undefined = searchParams.get('artistId')
? {
jfParams:
server?.type === ServerType.JELLYFIN
? {
artistIds: searchParams.get('artistId') as string,
}
: undefined,
ndParams:
server?.type === ServerType.NAVIDROME
? {
artist_id: searchParams.get('artistId') as string,
}
: undefined,
}
: undefined;
const pageKey = generatePageKey(
'album',
albumArtistId ? `${albumArtistId}_${server?.id}` : undefined,
);
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
const itemCountCheck = useAlbumList(
{
limit: 1,
startIndex: 0,
...filters,
...customFilters,
jfParams: {
...filters.jfParams,
...customFilters?.jfParams,
},
ndParams: {
...filters.ndParams,
...customFilters?.ndParams,
},
...albumListFilter,
},
{
cacheTime: 1000 * 60 * 60 * 2,
staleTime: 1000 * 60 * 60 * 2,
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
);
@@ -62,21 +43,19 @@ const AlbumListRoute = () => {
return (
<AnimatedPage>
<VirtualGridContainer>
<AlbumListContext.Provider value={{ id: albumArtistId || undefined, pageKey }}>
<AlbumListHeader
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
title={searchParams.get('artistName') || undefined}
/>
<AlbumListContent
customFilters={customFilters}
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</VirtualGridContainer>
</AlbumListContext.Provider>
</AnimatedPage>
);
};
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import {
Button,
DropdownMenu,
getColumnDefs,
GridCarousel,
Text,
@@ -10,7 +9,7 @@ import {
} from '/@/renderer/components';
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Group, Stack } from '@mantine/core';
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { generatePath, useParams } from 'react-router';
import { useCurrentServer } from '/@/renderer/store';
import { createSearchParams, Link } from 'react-router-dom';
@@ -18,15 +17,16 @@ import styled from 'styled-components';
import { AppRoute } from '/@/renderer/router/routes';
import { useContainerQuery } from '/@/renderer/hooks';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { Play, TableColumn } from '/@/renderer/types';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import {
PlayButton,
PLAY_TYPES,
useCreateFavorite,
useDeleteFavorite,
} from '/@/renderer/features/shared';
useHandleGeneralContextMenu,
useHandleTableContextMenu,
} from '/@/renderer/features/context-menu';
import { Play, TableColumn } from '/@/renderer/types';
import {
ARTIST_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,
@@ -44,7 +44,6 @@ const ContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: 3rem;
max-width: 1920px;
padding: 1rem 2rem 5rem;
overflow: hidden;
@@ -106,8 +105,8 @@ export const AlbumArtistDetailContent = () => {
});
const topSongsQuery = useTopSongsList(
{ artist: detailQuery?.data?.name || '' },
{ enabled: server?.type !== ServerType.JELLYFIN && !!detailQuery?.data?.name },
{ artist: detailQuery?.data?.name || '', artistId: albumArtistId },
{ enabled: !!detailQuery?.data?.name },
);
const topSongsColumnDefs: ColDef[] = useMemo(
@@ -165,8 +164,8 @@ export const AlbumArtistDetailContent = () => {
title: (
<>
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
Recent releases
</TextTitle>
@@ -193,8 +192,8 @@ export const AlbumArtistDetailContent = () => {
},
title: (
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
Appears on
</TextTitle>
@@ -211,8 +210,8 @@ export const AlbumArtistDetailContent = () => {
},
title: (
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
Related artists
</TextTitle>
@@ -266,11 +265,16 @@ export const AlbumArtistDetailContent = () => {
}
};
const handleGeneralContextMenu = useHandleGeneralContextMenu(
LibraryItem.ALBUM_ARTIST,
ARTIST_CONTEXT_MENU_ITEMS,
);
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
const showBiography =
detailQuery?.data?.biography !== undefined && detailQuery?.data?.biography !== null;
const showTopSongs = server?.type !== ServerType.JELLYFIN && topSongsQuery?.data?.items?.length;
const showTopSongs = topSongsQuery?.data?.items?.length;
const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false;
const isLoading =
@@ -281,7 +285,7 @@ export const AlbumArtistDetailContent = () => {
return (
<ContentContainer ref={cq.ref}>
<Box component="section">
<Group spacing="lg">
<Group spacing="md">
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
@@ -299,28 +303,16 @@ export const AlbumArtistDetailContent = () => {
<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>
<Button
compact
variant="subtle"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
>
<RiMoreFill size={20} />
</Button>
<Button
compact
uppercase
@@ -344,18 +336,18 @@ export const AlbumArtistDetailContent = () => {
</Box>
{showGenres && (
<Box component="section">
<Group>
<Group spacing="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
key={`genre-${genre.id}`}
compact
component={Link}
radius="md"
size="sm"
size="md"
to={generatePath(`${AppRoute.LIBRARY_ALBUM_ARTISTS}?genre=${genre.id}`, {
albumArtistId,
})}
variant="default"
variant="outline"
>
{genre.name}
</Button>
@@ -369,8 +361,8 @@ export const AlbumArtistDetailContent = () => {
maw="1280px"
>
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
About {detailQuery?.data?.name}
</TextTitle>
@@ -393,8 +385,8 @@ export const AlbumArtistDetailContent = () => {
align="flex-end"
>
<TextTitle
fw="bold"
order={3}
order={2}
weight={700}
>
Top Songs
</TextTitle>
@@ -410,22 +402,6 @@ export const AlbumArtistDetailContent = () => {
View all
</Button>
</Group>
<DropdownMenu>
<DropdownMenu.Target>
<Button
compact
uppercase
rightIcon={<RiArrowDownSLine size={20} />}
variant="subtle"
>
Community
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>Community</DropdownMenu.Item>
<DropdownMenu.Item>User</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<VirtualTable
autoFitColumns

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