Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a614a6ff9c | |||
| 08a1cb1ae9 | |||
| 75e93b8ea2 | |||
| 5d28abae91 | |||
| c483cdb871 | |||
| fe14ca25c6 | |||
| 0965efab21 | |||
| 598b627bb4 | |||
| b8614495f6 | |||
| e02643123c | |||
| 5d8cad06d7 | |||
| 2b8a97b8c2 | |||
| 44cd1b33bf | |||
| 90b503906f | |||
| 77bfb916ba | |||
| f35152a169 | |||
| 0d9224bc09 | |||
| cf4f80c82b | |||
| fa717b9bca | |||
| 8e2dce34f0 | |||
| dce6fc7e7d | |||
| 781e3c3c4d | |||
| 0d4d5b5de0 | |||
| 293d8ec584 | |||
| 6ccef6e515 | |||
| 5f7b005626 | |||
| b590636303 | |||
| a17e0adf44 | |||
| 2b1c1d5e59 | |||
| eecbcddea3 | |||
| 373441e4c6 | |||
| dbcda5b634 | |||
| bc5f1f13f0 | |||
| 0c13b09029 | |||
| 4ffc544e87 | |||
| cf00992d71 | |||
| 3848e9840d | |||
| 930bbb33fd | |||
| 4332a9ea3a | |||
| ccfe0bfd9d | |||
| f5fc56eee1 | |||
| cd6bf25011 | |||
| 335287d119 | |||
| 50af8f4d3a | |||
| 58c7370536 | |||
| ececc394e2 | |||
| 219a9ed613 | |||
| e47fcfc62e | |||
| 6cfdb8ff84 | |||
| ef4cdfa028 | |||
| 1eed26abab | |||
| a2851dd700 | |||
| 563db1138e | |||
| 84587da701 | |||
| f0a836fc1f | |||
| 5539e2cd4e | |||
| 30b013dfa5 | |||
| 8343f4f80b | |||
| e8dcba0456 | |||
| 27cbc23d87 | |||
| 275d68ec5b | |||
| 7f9de4b180 | |||
| 231f10cbe2 | |||
| b4664f45b4 | |||
| 3153cdd6c4 | |||
| 69292a083d | |||
| 123478a24f | |||
| 828cca9c19 | |||
| f7740407c3 | |||
| 157ac9f3a2 | |||
| f21c9010ac | |||
| 7c045e5b23 | |||
| ae292e3a5f | |||
| 918b77eebb | |||
| 661751f306 | |||
| 2260caba00 | |||
| 3fe0873dc1 | |||
| 7c6ec73617 | |||
| 76dcd1c28e | |||
| 4fb1f4d2cb | |||
| 92039b95c3 | |||
| c0a703be7a | |||
| f08538cbfb | |||
| 1fa975ccec | |||
| ac62c26099 | |||
| 7ae3d9d99a | |||
| a9cfcaeda6 | |||
| 3d8b25922e | |||
| a9089859ce | |||
| c878e36015 |
@@ -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) :
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)~~
|
||||
|
||||
|
After Width: | Height: | Size: 896 B |
|
After Width: | Height: | Size: 971 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 524 B |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 887 KiB |
|
After Width: | Height: | Size: 396 KiB |
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.0.1-alpha5",
|
||||
"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",
|
||||
@@ -15,7 +15,7 @@
|
||||
"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",
|
||||
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||
@@ -51,6 +51,7 @@
|
||||
"package.json"
|
||||
],
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"electronVersion": "22.3.1",
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "default",
|
||||
@@ -158,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",
|
||||
@@ -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,14 +254,14 @@
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^6.0.0-alpha.5",
|
||||
"@mantine/dates": "^6.0.0-alpha.5",
|
||||
"@mantine/dropzone": "^6.0.0-alpha.5",
|
||||
"@mantine/form": "^6.0.0-alpha.5",
|
||||
"@mantine/hooks": "^6.0.0-alpha.5",
|
||||
"@mantine/modals": "^6.0.0-alpha.5",
|
||||
"@mantine/notifications": "^6.0.0-alpha.5",
|
||||
"@mantine/utils": "^6.0.0-alpha.5",
|
||||
"@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",
|
||||
@@ -280,7 +281,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"nanoid": "^3.3.3",
|
||||
"net": "^1.0.2",
|
||||
"node-mpv": "^2.0.0-beta.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha5",
|
||||
"version": "0.0.1-alpha6",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -15,5 +15,8 @@
|
||||
"dependencies": {
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "22.3.1"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -1,73 +1,98 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { mpv } from '../../../main';
|
||||
import { getMpvInstance } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
function wait(timeout: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('resolved');
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
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 && !data.queue.next) {
|
||||
await mpv.clearPlaylist();
|
||||
await mpv.pause();
|
||||
await getMpvInstance()?.clearPlaylist();
|
||||
await getMpvInstance()?.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.queue.current) {
|
||||
await mpv.load(data.queue.current.streamUrl, 'replace');
|
||||
}
|
||||
let complete = false;
|
||||
|
||||
if (data.queue.next) {
|
||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
while (!complete) {
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
|
||||
}
|
||||
|
||||
await mpv.play();
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,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.stop();
|
||||
await getMpvInstance()?.stop();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,16 @@
|
||||
* `./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';
|
||||
@@ -18,7 +27,7 @@ import MpvAPI from 'node-mpv';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { store } from './features/core/settings/index';
|
||||
import MenuBuilder from './menu';
|
||||
import { resolveHtmlPath } from './utils';
|
||||
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||
import './features';
|
||||
|
||||
declare module 'node-mpv';
|
||||
@@ -31,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');
|
||||
@@ -57,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,
|
||||
@@ -85,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,
|
||||
});
|
||||
@@ -99,7 +212,6 @@ const createWindow = async () => {
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
mainWindow?.maximize();
|
||||
mainWindow?.webContents.send('renderer-player-quit');
|
||||
});
|
||||
|
||||
ipcMain.on('window-unmaximize', () => {
|
||||
@@ -143,14 +255,41 @@ const createWindow = async () => {
|
||||
mainWindow.minimize();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
createWinThumbarButtons();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
// mainWindow?.webContents.send('renderer-player-quit');
|
||||
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();
|
||||
|
||||
@@ -160,103 +299,122 @@ 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 BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
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?.includes('--gapless-audio=weak') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=no') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio')
|
||||
) {
|
||||
parameters.push('--gapless-audio=yes');
|
||||
}
|
||||
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
|
||||
) {
|
||||
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
export 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(),
|
||||
);
|
||||
let mpvInstance: MpvAPI | null = null;
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('error starting mpv', error);
|
||||
});
|
||||
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
const { extraParameters, properties } = data;
|
||||
|
||||
mpv.on('status', (status) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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-restart',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
mpvInstance?.quit();
|
||||
createMpv(data);
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
mpv.stop();
|
||||
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
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
} else {
|
||||
mpv.stop();
|
||||
if (isMacOS()) {
|
||||
mainWindow = null;
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -264,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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -10,101 +9,6 @@ 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -115,7 +115,7 @@ const endpoints: ApiController = {
|
||||
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jellyfinApi.getSongList,
|
||||
getTopSongs: undefined,
|
||||
getTopSongs: jellyfinApi.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||
scrobble: jellyfinApi.scrobble,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ky from 'ky';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import type {
|
||||
import {
|
||||
JFAddToPlaylist,
|
||||
JFAddToPlaylistParams,
|
||||
JFAlbum,
|
||||
@@ -35,8 +35,10 @@ import type {
|
||||
JFSongList,
|
||||
JFSongListParams,
|
||||
JFSongListResponse,
|
||||
JFSongListSort,
|
||||
JFCollectionType,
|
||||
JFSortOrder,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
@@ -72,17 +74,22 @@ import {
|
||||
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,
|
||||
@@ -322,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;
|
||||
|
||||
@@ -1003,6 +1040,7 @@ export const jellyfinApi = {
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
|
||||
@@ -567,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',
|
||||
|
||||
@@ -88,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: [
|
||||
@@ -122,6 +124,7 @@ const api = ky.create({
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const authenticate = async (
|
||||
@@ -278,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,
|
||||
};
|
||||
|
||||
@@ -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, ''));
|
||||
|
||||
@@ -52,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;
|
||||
@@ -93,6 +95,7 @@ const api = ky.create({
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const getDefaultParams = (server: ServerListItem | null) => {
|
||||
|
||||
@@ -343,6 +343,7 @@ export enum AlbumListSort {
|
||||
}
|
||||
|
||||
export type AlbumListQuery = {
|
||||
artistIds?: string[];
|
||||
jfParams?: {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
@@ -1009,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ 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,6 +36,20 @@ 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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
&:not([data-disabled])&: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) !important';
|
||||
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 '';
|
||||
}
|
||||
}};
|
||||
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)`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-disabled])&: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 '';
|
||||
}
|
||||
}};
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
||||
@@ -7,7 +7,7 @@ import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
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 {
|
||||
@@ -112,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();
|
||||
|
||||
@@ -19,12 +19,17 @@ 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: 10px;
|
||||
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: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
||||
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
||||
return (
|
||||
<StyledDatePicker
|
||||
withinPortal
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
/>
|
||||
|
||||
@@ -73,7 +73,7 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
|
||||
*:first-child {
|
||||
/* *:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
@@ -81,11 +81,12 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
*: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) => {
|
||||
@@ -97,7 +98,6 @@ export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transition="fade"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -34,3 +34,4 @@ export * from './context-menu';
|
||||
export * from './query-builder';
|
||||
export * from './rating';
|
||||
export * from './hover-card';
|
||||
export * from './option';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -15,7 +17,11 @@ const Container = styled(motion(Flex))<{
|
||||
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%;
|
||||
@@ -23,7 +29,7 @@ const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>`
|
||||
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;
|
||||
@@ -88,6 +94,7 @@ export const PageHeader = ({
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
@@ -98,6 +105,7 @@ export const PageHeader = ({
|
||||
{...props}
|
||||
>
|
||||
<Header
|
||||
$isDraggable={windowBarStyle === Platform.WEB}
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 || '65px'};
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) => props.scrollBarOffset || '65px'};
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -74,6 +78,7 @@ export const NativeScrollArea = forwardRef(
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(
|
||||
@@ -130,6 +135,7 @@ export const NativeScrollArea = forwardRef(
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
scrollBarOffset={scrollBarOffset}
|
||||
windowBarStyle={windowBarStyle}
|
||||
onMouseEnter={() => {
|
||||
setHideScrollbar(false);
|
||||
clear();
|
||||
|
||||
@@ -54,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}
|
||||
/>
|
||||
|
||||
@@ -64,8 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -126,8 +125,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,11 +12,14 @@ 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 {
|
||||
@@ -35,27 +38,26 @@ const StyledTabs = styled(MantineTabs)`
|
||||
button[data-active] {
|
||||
color: var(--btn-subtle-fg);
|
||||
background: none;
|
||||
box-shadow: 2px 0 0 var(--primary-color) inset;
|
||||
border-color: var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* button[data-active]::before {
|
||||
content: '';
|
||||
border-left: 2px solid var(--primary-color);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
} */
|
||||
`;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,37 +1,58 @@
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -40,26 +61,16 @@ const CardWrapper = styled.div<{
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--card-default-radius);
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div<{ size?: number }>`
|
||||
const ImageContainer = styled.div`
|
||||
position: relative;
|
||||
width: ${({ size }) => size && `${size - 24}px`};
|
||||
height: ${({ size }) => size && `${size - 24}px`};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--placeholder-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::before {
|
||||
@@ -78,151 +89,79 @@ const ImageSection = styled.div<{ size?: number }>`
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
object-fit: cover;
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
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
|
||||
height={cardSize}
|
||||
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,13 +1,12 @@
|
||||
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 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 {
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -28,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;
|
||||
@@ -63,6 +63,8 @@ const SecondaryButton = styled(_Button)`
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -76,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;
|
||||
`;
|
||||
|
||||
@@ -114,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();
|
||||
@@ -146,45 +137,41 @@ export const GridCardControls = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<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>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</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,60 +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 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 {
|
||||
@@ -70,150 +65,99 @@ const ImageSection = styled.div`
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ImageProps {
|
||||
height: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Image = styled.img<ImageProps>`
|
||||
const Image = styled.img`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
border-radius: var(--card-poster-radius);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
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
|
||||
height={sizes.itemWidth}
|
||||
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,16 +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((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) => {
|
||||
@@ -153,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) => {
|
||||
|
||||
@@ -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: '-68px 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 };
|
||||
};
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
RawRatingResponse,
|
||||
RatingArgs,
|
||||
Album,
|
||||
Song,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
LibraryItem,
|
||||
AnyLibraryItems,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
useCurrentServer,
|
||||
@@ -31,7 +30,7 @@ export const useUpdateRating = () => {
|
||||
RawRatingResponse,
|
||||
HTTPError,
|
||||
Omit<RatingArgs, 'server'>,
|
||||
{ previous: { items: Album[] | Song[] | AlbumArtist[] | Artist[] } | undefined }
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
|
||||
@@ -264,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,
|
||||
@@ -293,7 +293,7 @@ const tableColumns: { [key: string]: ColDef } = {
|
||||
width: 50,
|
||||
},
|
||||
userRating: {
|
||||
cellClass: (params) => (params.value.userRating ? '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',
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Divider, 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,42 +167,49 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
p="1rem"
|
||||
spacing="md"
|
||||
>
|
||||
<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>
|
||||
<Divider my="0.5rem" />
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.autoFit}
|
||||
label="Auto-fit columns"
|
||||
onChange={handleUpdateAutoFit}
|
||||
/>
|
||||
<Switch
|
||||
defaultChecked={tableConfig[type]?.followCurrentSong}
|
||||
label="Follow current song"
|
||||
onChange={handleUpdateFollow}
|
||||
/>
|
||||
</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,7 +104,6 @@ export const TablePagination = ({
|
||||
trapFocus
|
||||
opened={isGoToPageOpen}
|
||||
position="bottom-start"
|
||||
transition="fade"
|
||||
onClose={() => handlers.close()}
|
||||
>
|
||||
<Popover.Target>
|
||||
@@ -142,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>
|
||||
|
||||
@@ -304,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>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Rating, Text } from '/@/renderer/components';
|
||||
import { Button, Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
@@ -104,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}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,12 +98,8 @@ export const AlbumListContent = ({
|
||||
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(
|
||||
@@ -133,19 +108,28 @@ export const AlbumListContent = ({
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
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(() => {
|
||||
@@ -154,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);
|
||||
@@ -162,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(
|
||||
@@ -185,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,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);
|
||||
@@ -289,7 +260,7 @@ export const AlbumListContent = ({
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [page.filter.sortBy]);
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
|
||||
|
||||
@@ -326,24 +297,23 @@ 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 === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
@@ -351,7 +321,6 @@ export const AlbumListContent = ({
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
setItemData={customFilters ? setLocalItemData : setItemData}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
@@ -362,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 || 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}
|
||||
@@ -393,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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
|
||||
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,
|
||||
@@ -29,21 +30,19 @@ import {
|
||||
Text,
|
||||
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 { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumFilters,
|
||||
useSetAlbumStore,
|
||||
useSetAlbumTable,
|
||||
useSetAlbumTablePagination,
|
||||
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: [
|
||||
@@ -94,25 +93,21 @@ export const AlbumListHeaderFilters = ({
|
||||
itemCount,
|
||||
}: AlbumListHeaderFiltersProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const setPage = useSetAlbumStore();
|
||||
const setFilter = useSetAlbumFilters();
|
||||
const page = useAlbumListStore();
|
||||
const filters = page.filter;
|
||||
const { setFilter, setTablePagination, setTable, setGrid, setDisplayType, setTableColumns } =
|
||||
useListStoreActions();
|
||||
const { display, filter, table, grid } = useAlbumListStore({ id, key: pageKey });
|
||||
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) ||
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filter.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const fetch = useCallback(
|
||||
async (skip: number, take: number, filters: AlbumListFilter) => {
|
||||
@@ -151,10 +146,7 @@ export const AlbumListHeaderFilters = ({
|
||||
|
||||
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;
|
||||
@@ -197,8 +189,8 @@ export const AlbumListHeaderFilters = ({
|
||||
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);
|
||||
@@ -213,7 +205,7 @@ export const AlbumListHeaderFilters = ({
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, customFilters, server, queryClient, setPagination, gridRef, fetch],
|
||||
[display, tableRef, customFilters, server, queryClient, setTablePagination, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleOpenFiltersModal = () => {
|
||||
@@ -224,11 +216,15 @@ export const AlbumListHeaderFilters = ({
|
||||
<NavidromeAlbumFilters
|
||||
disableArtistFilter={!!customFilters}
|
||||
handleFilterChange={handleFilterChange}
|
||||
id={id}
|
||||
pageKey={pageKey}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
disableArtistFilter={!!customFilters}
|
||||
handleFilterChange={handleFilterChange}
|
||||
id={id}
|
||||
pageKey={pageKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -239,8 +235,8 @@ export const AlbumListHeaderFilters = ({
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries(queryKeys.albums.list(server?.id || ''));
|
||||
handleFilterChange(filters);
|
||||
}, [filters, handleFilterChange, queryClient, server?.id]);
|
||||
handleFilterChange(filter);
|
||||
}, [filter, handleFilterChange, queryClient, server?.id]);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -251,9 +247,12 @@ export const AlbumListHeaderFilters = ({
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
data: {
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
},
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
@@ -265,22 +264,31 @@ export const AlbumListHeaderFilters = ({
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
if (e.currentTarget.value === String(filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: undefined },
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: e.currentTarget.value },
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
[handleFilterChange, filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({
|
||||
data: { sortOrder: newSortOrder },
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
}, [filter.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
@@ -289,14 +297,14 @@ export const AlbumListHeaderFilters = ({
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...filter,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
@@ -320,30 +328,30 @@ export const AlbumListHeaderFilters = ({
|
||||
};
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setTable({ data: { rowHeight: e }, key: 'album' });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
setGrid({ data: { itemsPerRow: e }, key: 'album' });
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: 'album' });
|
||||
},
|
||||
[page, setPage],
|
||||
[setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
return setTableColumns({
|
||||
data: [],
|
||||
key: 'album',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,20 +359,20 @@ export const AlbumListHeaderFilters = ({
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
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));
|
||||
|
||||
setTable({ columns: newColumns });
|
||||
setTableColumns({ data: newColumns, key: 'album' });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: 'album' });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
@@ -374,16 +382,16 @@ export const AlbumListHeaderFilters = ({
|
||||
const isFilterApplied = useMemo(() => {
|
||||
const isNavidromeFilterApplied =
|
||||
server?.type === ServerType.NAVIDROME &&
|
||||
page.filter.ndParams &&
|
||||
Object.values(page.filter.ndParams).some((value) => value !== undefined);
|
||||
filter.ndParams &&
|
||||
Object.values(filter.ndParams).some((value) => value !== undefined);
|
||||
|
||||
const isJellyfinFilterApplied =
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
page.filter.jfParams &&
|
||||
Object.values(page.filter.jfParams).some((value) => value !== undefined);
|
||||
filter.jfParams &&
|
||||
Object.values(filter.jfParams).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [page.filter.jfParams, page.filter.ndParams, server?.type]);
|
||||
}, [filter.jfParams, filter.ndParams, server?.type]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
@@ -404,14 +412,14 @@ export const AlbumListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
key={`filter-${f.name}`}
|
||||
$isActive={f.value === filter.sortBy}
|
||||
value={f.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
{f.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
@@ -427,7 +435,7 @@ export const AlbumListHeaderFilters = ({
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
{filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
@@ -451,7 +459,7 @@ export const AlbumListHeaderFilters = ({
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
@@ -528,50 +536,53 @@ export const AlbumListHeaderFilters = ({
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table (paginated)
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>Item size</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER
|
||||
? 'Items per row'
|
||||
: 'Item size'}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER
|
||||
? page.grid.size
|
||||
: page.table.rowHeight
|
||||
display === ListDisplayType.CARD || display === ListDisplayType.POSTER
|
||||
? grid?.itemsPerRow || 0
|
||||
: table.rowHeight
|
||||
}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleItemSize}
|
||||
max={14}
|
||||
min={2}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
{(display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
@@ -583,14 +594,14 @@ export const AlbumListHeaderFilters = ({
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUM_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
defaultValue={table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -9,26 +9,21 @@ import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import {
|
||||
PageHeader,
|
||||
Paper,
|
||||
SearchInput,
|
||||
SpinnerIcon,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
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,
|
||||
useSetAlbumTablePagination,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play } from '/@/renderer/types';
|
||||
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
@@ -47,12 +42,11 @@ export const AlbumListHeader = ({
|
||||
}: AlbumListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setFilter = useSetAlbumFilters();
|
||||
const page = useAlbumListStore();
|
||||
const filters = page.filter;
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetAlbumTablePagination();
|
||||
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) => {
|
||||
@@ -91,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;
|
||||
@@ -137,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);
|
||||
@@ -153,13 +144,13 @@ export const AlbumListHeader = ({
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, customFilters, server, queryClient, setPagination, gridRef, fetch],
|
||||
[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);
|
||||
|
||||
@@ -171,14 +162,14 @@ export const AlbumListHeader = ({
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...filter,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
};
|
||||
@@ -214,32 +205,27 @@ export const AlbumListHeader = ({
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
|
||||
<LibraryHeaderBar.Title>{title || 'Albums'}</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<FilterBar>
|
||||
<AlbumListHeaderFilters
|
||||
customFilters={customFilters}
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
@@ -10,14 +10,18 @@ import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-a
|
||||
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);
|
||||
@@ -38,9 +42,16 @@ 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,
|
||||
@@ -49,34 +60,43 @@ export const JellyfinAlbumFilters = ({
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
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 | string) => {
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
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?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
genreIds: genreFilterString,
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
genreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
});
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 250);
|
||||
|
||||
@@ -105,12 +125,15 @@ export const JellyfinAlbumFilters = ({
|
||||
|
||||
const handleAlbumArtistFilter = (e: string[] | null) => {
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
albumArtistIds: albumArtistFilterString,
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
albumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
});
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +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 { 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';
|
||||
@@ -10,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);
|
||||
|
||||
@@ -30,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);
|
||||
|
||||
@@ -43,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,
|
||||
@@ -53,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,
|
||||
@@ -63,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,
|
||||
@@ -73,12 +95,15 @@ 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,
|
||||
@@ -86,12 +111,15 @@ export const NavidromeAlbumFilters = ({
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string) => {
|
||||
const updatedFilters = setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
});
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
@@ -120,12 +148,15 @@ export const NavidromeAlbumFilters = ({
|
||||
}, [albumArtistListQuery?.data?.items]);
|
||||
|
||||
const handleAlbumArtistFilter = (e: string | null) => {
|
||||
const updatedFilters = setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
artist_id: e || undefined,
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
artist_id: e || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
import { AnimatedPage, LibraryHeaderBar, PlayButton } from '/@/renderer/features/shared';
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
@@ -42,7 +42,7 @@ const AlbumDetailRoute = () => {
|
||||
backgroundColor: background,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.PlayButton onClick={handlePlay} />
|
||||
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,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(
|
||||
@@ -274,7 +274,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
|
||||
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 =
|
||||
@@ -336,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>
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// import { useMemo, useRef } from 'react';
|
||||
// import { ColDef } from '@ag-grid-community/core';
|
||||
// import { Box, Group, Stack } from '@mantine/core';
|
||||
// import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
// import { useParams } from 'react-router';
|
||||
// import { AlbumListSort, SortOrder, SongListSort, Song } from '/@/renderer/api/types';
|
||||
// import {
|
||||
// getColumnDefs,
|
||||
// VirtualTable,
|
||||
// Text,
|
||||
// TextTitle,
|
||||
// NativeScrollArea,
|
||||
// } from '/@/renderer/components';
|
||||
// import { useAlbumList } from '/@/renderer/features/albums';
|
||||
// import { PlayButton } from '/@/renderer/features/shared';
|
||||
// import { useSongList } from '/@/renderer/features/songs';
|
||||
// import { useSongListStore } from '/@/renderer/store';
|
||||
// import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
// import { Play } from '/@/renderer/types';
|
||||
|
||||
// const RowVirtualizer = ({
|
||||
// rows,
|
||||
// columnDefs,
|
||||
// handlePlay,
|
||||
// rowVirtualizer,
|
||||
// }: {
|
||||
// columnDefs: ColDef[];
|
||||
// handlePlay: (play: Play, data: any[]) => void;
|
||||
// rowVirtualizer: Virtualizer<any, Element>;
|
||||
// rows: any[];
|
||||
// }) => {
|
||||
// const items = rowVirtualizer.getVirtualItems();
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// style={{
|
||||
// height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
// position: 'relative',
|
||||
// width: '100%',
|
||||
// }}
|
||||
// >
|
||||
// {items.map((virtualRow) => (
|
||||
// <div
|
||||
// key={rows?.[virtualRow.index].id}
|
||||
// style={{
|
||||
// height: `${(rows?.[virtualRow.index].songs?.length || 0) * 60 + 300}px`,
|
||||
// left: 0,
|
||||
// position: 'absolute',
|
||||
// top: 0,
|
||||
// transform: `translateY(${virtualRow.start}px)`,
|
||||
// width: '100%',
|
||||
// }}
|
||||
// >
|
||||
// <Stack
|
||||
// p="2rem"
|
||||
// spacing="lg"
|
||||
// >
|
||||
// <Group noWrap>
|
||||
// <img
|
||||
// alt={`${rows?.[virtualRow.index]?.name}-cover`}
|
||||
// height={150}
|
||||
// src={rows?.[virtualRow.index]?.imageUrl}
|
||||
// width={150}
|
||||
// />
|
||||
// <Stack>
|
||||
// <TextTitle
|
||||
// order={1}
|
||||
// weight={700}
|
||||
// >
|
||||
// {rows?.[virtualRow.index]?.name}
|
||||
// </TextTitle>
|
||||
// <Text $secondary>{rows?.[virtualRow.index]?.releaseYear}</Text>
|
||||
// <PlayButton
|
||||
// h="35px"
|
||||
// w="35px"
|
||||
// onClick={() => handlePlay?.(Play.NOW, rows?.[virtualRow.index]?.songs)}
|
||||
// />
|
||||
// </Stack>
|
||||
// </Group>
|
||||
// <Box sx={{ height: `${(rows?.[virtualRow.index].songs?.length || 0) * 60 + 60}px` }}>
|
||||
// <VirtualTable
|
||||
// autoFitColumns
|
||||
// suppressCellFocus
|
||||
// suppressHorizontalScroll
|
||||
// suppressLoadingOverlay
|
||||
// suppressRowDrag
|
||||
// transparentHeader
|
||||
// columnDefs={columnDefs}
|
||||
// getRowId={(data) => data.data.id}
|
||||
// rowData={rows?.[virtualRow.index]?.songs}
|
||||
// rowHeight={60}
|
||||
// rowModelType="clientSide"
|
||||
// rowSelection="multiple"
|
||||
// />
|
||||
// </Box>
|
||||
// </Stack>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export const AlbumArtistDiscographyDetailList = () => {
|
||||
// const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
// // const albumArtistQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
// const albumsQuery = useAlbumList({
|
||||
// jfParams: { artistIds: albumArtistId },
|
||||
// ndParams: { artist_id: albumArtistId },
|
||||
// sortBy: AlbumListSort.YEAR,
|
||||
// sortOrder: SortOrder.DESC,
|
||||
// startIndex: 0,
|
||||
// });
|
||||
|
||||
// const songsQuery = useSongList(
|
||||
// {
|
||||
// albumIds: albumsQuery.data?.items?.map((album) => album.id),
|
||||
// sortBy: SongListSort.ALBUM,
|
||||
// sortOrder: SortOrder.ASC,
|
||||
// startIndex: 0,
|
||||
// },
|
||||
// {
|
||||
// enabled: !albumsQuery.isLoading,
|
||||
// },
|
||||
// );
|
||||
|
||||
// const songsByAlbum = useMemo(() => {
|
||||
// if (songsQuery.isLoading || albumsQuery.isLoading) return null;
|
||||
|
||||
// const songsByAlbumMap = songsQuery?.data?.items?.reduce((acc, song) => {
|
||||
// if (!acc[song.albumId as keyof typeof acc]) {
|
||||
// acc[song.albumId as keyof typeof acc] = [];
|
||||
// }
|
||||
|
||||
// acc[song.albumId as keyof typeof acc].push(song);
|
||||
|
||||
// return acc;
|
||||
// }, {} as Record<string, Song[]>);
|
||||
|
||||
// const albumDetailWithSongs = albumsQuery?.data?.items?.map((album) => {
|
||||
// return {
|
||||
// ...album,
|
||||
// songs: songsByAlbumMap?.[album.id],
|
||||
// };
|
||||
// });
|
||||
|
||||
// return albumDetailWithSongs;
|
||||
// }, [
|
||||
// albumsQuery?.data?.items,
|
||||
// albumsQuery?.isLoading,
|
||||
// songsQuery?.data?.items,
|
||||
// songsQuery?.isLoading,
|
||||
// ]);
|
||||
|
||||
// const page = useSongListStore();
|
||||
|
||||
// const columnDefs: ColDef[] = useMemo(
|
||||
// () =>
|
||||
// getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'),
|
||||
// [page.table.columns],
|
||||
// );
|
||||
|
||||
// const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
// const parentRef = useRef<any>();
|
||||
|
||||
// const rowVirtualizer = useVirtualizer({
|
||||
// count: songsByAlbum?.length || 0,
|
||||
// estimateSize: (i) => (songsByAlbum?.[i].songs?.length || 0) * 60 + 300,
|
||||
// getScrollElement: () => parentRef.current,
|
||||
// overscan: 3,
|
||||
// });
|
||||
|
||||
// const handlePlay = (play: Play, data: any[]) => {
|
||||
// handlePlayQueueAdd?.({
|
||||
// byData: data,
|
||||
// play,
|
||||
// });
|
||||
// };
|
||||
|
||||
// if (albumsQuery.isLoading || songsQuery.isLoading) return null;
|
||||
|
||||
// return (
|
||||
// <NativeScrollArea
|
||||
// ref={parentRef}
|
||||
// scrollBarOffset="0"
|
||||
// >
|
||||
// {songsByAlbum && (
|
||||
// <RowVirtualizer
|
||||
// columnDefs={columnDefs}
|
||||
// handlePlay={handlePlay}
|
||||
// rowVirtualizer={rowVirtualizer}
|
||||
// rows={songsByAlbum}
|
||||
// />
|
||||
// )}
|
||||
// </NativeScrollArea>
|
||||
// );
|
||||
// };
|
||||
@@ -1,473 +0,0 @@
|
||||
import type { 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 { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiFilter3Line,
|
||||
RiFolder2Line,
|
||||
RiMoreFill,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
} from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
PageHeader,
|
||||
Slider,
|
||||
TextTitle,
|
||||
Switch,
|
||||
MultiSelect,
|
||||
Text,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Badge,
|
||||
SpinnerIcon,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { JellyfinSongFilters } from '/@/renderer/features/songs/components/jellyfin-song-filters';
|
||||
import { NavidromeSongFilters } from '/@/renderer/features/songs/components/navidrome-song-filters';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongFilters,
|
||||
useSetSongStore,
|
||||
useSetSongTable,
|
||||
useSetSongTablePagination,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface SongListHeaderProps {
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistDiscographyHeader = ({ itemCount, tableRef }: SongListHeaderProps) => {
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
const setPage = useSetSongStore();
|
||||
const setFilter = useSetSongFilters();
|
||||
const setTable = useSetSongTable();
|
||||
const setPagination = useSetSongTablePagination();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === page.filter.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === page.filter.sortOrder)?.name;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const pageFilters = filters || page.filter;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ currentPage: 0 });
|
||||
},
|
||||
[page.filter, server, setPagination, tableRef],
|
||||
);
|
||||
|
||||
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 SongListSort,
|
||||
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 = page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [page.filter.sortOrder, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ currentPage: 0 });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
|
||||
// const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
// const previousSearchTerm = page.filter.searchTerm;
|
||||
// const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
// const updatedFilters = setFilter({ searchTerm });
|
||||
// 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 };
|
||||
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.songs.list(server?.id || ''));
|
||||
handleFilterChange(page.filter);
|
||||
};
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const query: SongListQuery = { startIndex: 0, ...page.filter };
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: query,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<Flex
|
||||
ref={cq.ref}
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
<Group noWrap>
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
Tracks
|
||||
</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.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.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_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 === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{page.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}
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={page.filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Filters' : <RiFilter3Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeSongFilters handleFilterChange={handleFilterChange} />
|
||||
) : (
|
||||
<JellyfinSongFilters handleFilterChange={handleFilterChange} />
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
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>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongTable,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TablePagination as TablePaginationType } from '/@/renderer/types';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
||||
interface AlbumArtistSongListContentProps {
|
||||
filter: SongListFilter;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailSongListContent = ({
|
||||
itemCount,
|
||||
filter,
|
||||
tableRef,
|
||||
}: AlbumArtistSongListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
|
||||
const [pagination, setPagination] = useSetState<TablePaginationType>({
|
||||
currentPage: 0,
|
||||
itemsPerPage: 100,
|
||||
totalItems: itemCount || 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const setTable = useSetSongTable();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
() => getColumnDefs(page.table.columns),
|
||||
[page.table.columns],
|
||||
);
|
||||
|
||||
const onGridReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
...filter,
|
||||
limit,
|
||||
startIndex,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const onPaginationChanged = 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');
|
||||
|
||||
setPagination({
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
);
|
||||
|
||||
const handleColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = page.table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!page.table.autoFit && {
|
||||
width: column.getActualWidth(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ columns: updatedColumns });
|
||||
}, [page.table.autoFit, page.table.columns, setTable, tableRef]);
|
||||
|
||||
const debouncedColumnChange = debounce(handleColumnChange, 200);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<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}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
animateRows
|
||||
maintainColumnOrder
|
||||
suppressCopyRowsToClipboard
|
||||
suppressMoveWhenRowDragging
|
||||
suppressPaginationPanel
|
||||
suppressRowDrag
|
||||
suppressScrollOnNewData
|
||||
autoFitColumns={page.table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
cacheBlockSize={500}
|
||||
cacheOverflowSize={1}
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
rowSelection="multiple"
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onGridReady={onGridReady}
|
||||
onPaginationChanged={onPaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,470 +0,0 @@
|
||||
import type { 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 debounce from 'lodash/debounce';
|
||||
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
|
||||
import { RiArrowDownSLine, RiFolder2Line, RiMoreFill, RiSortAsc, RiSortDesc } from 'react-icons/ri';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
SongListQuery,
|
||||
SongListSort,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
PageHeader,
|
||||
SearchInput,
|
||||
Slider,
|
||||
TextTitle,
|
||||
Switch,
|
||||
MultiSelect,
|
||||
Text,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Badge,
|
||||
SpinnerIcon,
|
||||
} from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { queryClient } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
SongListFilter,
|
||||
useCurrentServer,
|
||||
useSetSongStore,
|
||||
useSetSongTable,
|
||||
useSetSongTablePagination,
|
||||
useSongListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Most Played', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Random', value: SongListSort.RANDOM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Release Date', value: SongListSort.RELEASE_DATE },
|
||||
],
|
||||
navidrome: [
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album', value: SongListSort.ALBUM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Album Artist', value: SongListSort.ALBUM_ARTIST },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Artist', value: SongListSort.ARTIST },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'BPM', value: SongListSort.BPM },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Channels', value: SongListSort.CHANNELS },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Comment', value: SongListSort.COMMENT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Duration', value: SongListSort.DURATION },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Favorited', value: SongListSort.FAVORITED },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Genre', value: SongListSort.GENRE },
|
||||
{ defaultOrder: SortOrder.ASC, name: 'Name', value: SongListSort.NAME },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Play Count', value: SongListSort.PLAY_COUNT },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Rating', value: SongListSort.RATING },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Added', value: SongListSort.RECENTLY_ADDED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Recently Played', value: SongListSort.RECENTLY_PLAYED },
|
||||
{ defaultOrder: SortOrder.DESC, name: 'Year', value: SongListSort.YEAR },
|
||||
],
|
||||
};
|
||||
|
||||
const ORDER = [
|
||||
{ name: 'Ascending', value: SortOrder.ASC },
|
||||
{ name: 'Descending', value: SortOrder.DESC },
|
||||
];
|
||||
|
||||
interface AlbumArtistDetailSongListHeaderProps {
|
||||
filter: SongListFilter;
|
||||
itemCount?: number;
|
||||
setFilter: (filter: Partial<SongListFilter>) => void;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailSongListHeader = ({
|
||||
filter,
|
||||
setFilter,
|
||||
title,
|
||||
itemCount,
|
||||
tableRef,
|
||||
}: AlbumArtistDetailSongListHeaderProps) => {
|
||||
const server = useCurrentServer();
|
||||
const page = useSongListStore();
|
||||
const setPage = useSetSongStore();
|
||||
const setTable = useSetSongTable();
|
||||
const setPagination = useSetSongTablePagination();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
(FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[]).find(
|
||||
(f) => f.value === filter.sortBy,
|
||||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === filter.sortOrder)?.name;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters?: SongListFilter) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const pageFilters = filters || filter;
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
});
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ currentPage: 0 });
|
||||
},
|
||||
[filter, server, setPagination, tableRef],
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
setFilter({
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
|
||||
handleFilterChange({
|
||||
...filter,
|
||||
sortBy: e.currentTarget.value as SongListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
},
|
||||
[filter, 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 = { musicFolderId: undefined };
|
||||
setFilter(updatedFilters);
|
||||
} else {
|
||||
updatedFilters = { musicFolderId: e.currentTarget.value };
|
||||
setFilter(updatedFilters);
|
||||
}
|
||||
|
||||
handleFilterChange({ ...filter, ...updatedFilters });
|
||||
},
|
||||
[filter, handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
setFilter({ sortOrder: newSortOrder });
|
||||
handleFilterChange({ ...filter, sortOrder: newSortOrder });
|
||||
}, [filter, handleFilterChange, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
const display = e.currentTarget.value as ListDisplayType;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ currentPage: 0 });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
);
|
||||
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
setFilter({ searchTerm });
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange({ ...filter, searchTerm });
|
||||
}, 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 };
|
||||
|
||||
return setTable({ columns: [...existingColumns, newColumn] });
|
||||
}
|
||||
|
||||
// If removing a column
|
||||
const removed = existingColumns.filter((column) => !values.includes(column.column));
|
||||
const newColumns = existingColumns.filter((column) => !removed.includes(column));
|
||||
|
||||
return setTable({ columns: newColumns });
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowHeight = (e: number) => {
|
||||
setTable({ rowHeight: e });
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries(queryKeys.songs.list(server?.id || ''));
|
||||
handleFilterChange(filter);
|
||||
};
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
const query: SongListQuery = { startIndex: 0, ...filter };
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: query,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
play,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader p="1rem">
|
||||
<Flex
|
||||
ref={cq.ref}
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
<Group noWrap>
|
||||
<TextTitle
|
||||
maw="20vw"
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
weight={700}
|
||||
>
|
||||
{title}
|
||||
</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.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.table.rowHeight || 0}
|
||||
label={null}
|
||||
max={100}
|
||||
min={25}
|
||||
onChangeEnd={handleRowHeight}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={SONG_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 === page.filter.sortBy}
|
||||
value={filter.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
variant="subtle"
|
||||
onClick={handleToggleSortOrder}
|
||||
>
|
||||
{cq.isMd ? (
|
||||
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"
|
||||
variant="subtle"
|
||||
>
|
||||
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={page.filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
{folder.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="600"
|
||||
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>
|
||||
</Flex>
|
||||
<Flex gap="md">
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isLg ? 300 : cq.isMd ? 250 : cq.isSm ? 150 : 75}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
@@ -16,15 +16,7 @@ import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtist, AlbumArtistListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumArtistListStore,
|
||||
useAlbumArtistTablePagination,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistTable,
|
||||
useSetAlbumArtistTablePagination,
|
||||
useAlbumArtistListItemData,
|
||||
} from '/@/renderer/store';
|
||||
import { useCurrentServer, useAlbumArtistListStore } from '/@/renderer/store';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
BodyScrollEvent,
|
||||
@@ -41,6 +33,8 @@ import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/cont
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useAlbumArtistListFilter, useListStoreActions } from '../../../store/list.store';
|
||||
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
|
||||
interface AlbumArtistListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -51,23 +45,20 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const page = useAlbumArtistListStore();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const { itemData, setItemData } = useAlbumArtistListItemData();
|
||||
const { id, pageKey } = useAlbumArtistListContext();
|
||||
const filter = useAlbumArtistListFilter({ id, key: pageKey });
|
||||
const { table, grid, display } = useAlbumArtistListStore();
|
||||
const { setTable, setTablePagination, setGrid } = useListStoreActions();
|
||||
|
||||
const pagination = useAlbumArtistTablePagination();
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkAlbumArtistList = useAlbumArtistList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...page.filter,
|
||||
...filter,
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
@@ -75,10 +66,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
},
|
||||
);
|
||||
|
||||
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) => {
|
||||
@@ -90,7 +78,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
...filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
@@ -100,7 +88,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
...filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
@@ -115,9 +103,9 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
};
|
||||
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top');
|
||||
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
|
||||
},
|
||||
[page.filter, page.table.scrollOffset, queryClient, server],
|
||||
[filter, table.scrollOffset, queryClient, server],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
@@ -126,19 +114,28 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage;
|
||||
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,
|
||||
pageKey,
|
||||
setTablePagination,
|
||||
table.pagination.currentPage,
|
||||
table.pagination.itemsPerPage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
@@ -147,7 +144,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
|
||||
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);
|
||||
@@ -155,21 +152,21 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
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) => {
|
||||
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(
|
||||
@@ -177,7 +174,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
...filter,
|
||||
});
|
||||
|
||||
const albumArtistsRes = await queryClient.fetchQuery(
|
||||
@@ -187,7 +184,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
...filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
@@ -197,26 +194,18 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
|
||||
return api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
},
|
||||
[page.filter, queryClient, server],
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setPage({
|
||||
list: {
|
||||
...page,
|
||||
grid: {
|
||||
...page.grid,
|
||||
scrollOffset: e.scrollOffset,
|
||||
},
|
||||
},
|
||||
});
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
},
|
||||
[page, setPage],
|
||||
[pageKey, setGrid],
|
||||
);
|
||||
|
||||
const handleGridSizeChange = () => {
|
||||
if (page.table.autoFit) {
|
||||
if (table.autoFit) {
|
||||
tableRef?.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
@@ -224,7 +213,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<AlbumArtist>[] = [ALBUMARTIST_CARD_ROWS.name];
|
||||
|
||||
switch (page.filter.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case AlbumArtistListSort.DURATION:
|
||||
rows.push(ALBUMARTIST_CARD_ROWS.duration);
|
||||
break;
|
||||
@@ -253,7 +242,7 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [page.filter.sortBy]);
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
@@ -267,22 +256,20 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
{page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? (
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`albumartist-list-${server?.id}-${page.display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={page.display || ListDisplayType.CARD}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={page?.grid.scrollOffset || 0}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={20}
|
||||
itemSize={150 + page.grid?.size}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
loading={checkAlbumArtistList.isLoading}
|
||||
minimumBatchSize={40}
|
||||
@@ -290,7 +277,6 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
}}
|
||||
setItemData={setItemData}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
@@ -300,18 +286,18 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
<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}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={checkAlbumArtistList.data?.totalRecordCount || 1}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={page.table.pagination.itemsPerPage || 100}
|
||||
rowHeight={page.table.rowHeight || 40}
|
||||
paginationPageSize={table.pagination.itemsPerPage || 100}
|
||||
rowHeight={table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
@@ -330,10 +316,11 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IDatasource } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group, Stack, Flex } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
@@ -28,14 +29,13 @@ import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumArtistStore,
|
||||
useSetAlbumArtistFilters,
|
||||
useAlbumArtistListStore,
|
||||
useSetAlbumArtistTablePagination,
|
||||
useSetAlbumArtistTable,
|
||||
AlbumArtistListFilter,
|
||||
useListStoreActions,
|
||||
useAlbumArtistListFilter,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn, ServerType } from '/@/renderer/types';
|
||||
import { useAlbumArtistListContext } from '../context/album-artist-list-context';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -76,35 +76,32 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
}: AlbumArtistListHeaderFiltersProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setPage = useSetAlbumArtistStore();
|
||||
const setFilter = useSetAlbumArtistFilters();
|
||||
const page = useAlbumArtistListStore();
|
||||
const filters = page.filter;
|
||||
const { pageKey } = useAlbumArtistListContext();
|
||||
const { display, table, grid } = useAlbumArtistListStore();
|
||||
const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } =
|
||||
useListStoreActions();
|
||||
const filter = useAlbumArtistListFilter({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
const setTable = useSetAlbumArtistTable();
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) ||
|
||||
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown';
|
||||
const sortOrderLabel = ORDER.find((o) => o.value === filter.sortOrder)?.name || 'Unknown';
|
||||
|
||||
const handleItemSize = (e: number) => {
|
||||
if (
|
||||
page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED
|
||||
) {
|
||||
setTable({ rowHeight: e });
|
||||
if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setTable({ data: { rowHeight: e }, key: pageKey });
|
||||
} else {
|
||||
setPage({ list: { ...page, grid: { ...page.grid, size: e } } });
|
||||
setGrid({ data: { itemsPerRow: e }, key: pageKey });
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedHandleItemSize = debounce(handleItemSize, 20);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
@@ -135,10 +132,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumArtistListFilter) => {
|
||||
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;
|
||||
@@ -177,8 +171,8 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
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: pageKey });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
@@ -193,7 +187,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
[display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch],
|
||||
);
|
||||
|
||||
const handleSetSortBy = useCallback(
|
||||
@@ -205,13 +199,16 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
)?.defaultOrder;
|
||||
|
||||
const updatedFilters = setFilter({
|
||||
sortBy: e.currentTarget.value as AlbumArtistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
});
|
||||
data: {
|
||||
sortBy: e.currentTarget.value as AlbumArtistListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, server?.type, setFilter],
|
||||
[handleFilterChange, pageKey, server?.type, setFilter],
|
||||
);
|
||||
|
||||
const handleSetMusicFolder = useCallback(
|
||||
@@ -219,37 +216,50 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
if (!e.currentTarget?.value) return;
|
||||
|
||||
let updatedFilters = null;
|
||||
if (e.currentTarget.value === String(page.filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({ musicFolderId: undefined });
|
||||
if (e.currentTarget.value === String(filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: undefined },
|
||||
key: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
} else {
|
||||
updatedFilters = setFilter({ musicFolderId: e.currentTarget.value });
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: e.currentTarget.value },
|
||||
key: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
}
|
||||
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
[handleFilterChange, page.filter.musicFolderId, setFilter],
|
||||
[filter.musicFolderId, handleFilterChange, setFilter, pageKey],
|
||||
);
|
||||
|
||||
const handleToggleSortOrder = useCallback(() => {
|
||||
const newSortOrder = filters.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({ sortOrder: newSortOrder });
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({
|
||||
data: { sortOrder: newSortOrder },
|
||||
key: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
}, [filters.sortOrder, handleFilterChange, setFilter]);
|
||||
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
|
||||
|
||||
const handleSetViewType = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } });
|
||||
|
||||
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
|
||||
},
|
||||
[page, setPage],
|
||||
[pageKey, setDisplayType],
|
||||
);
|
||||
|
||||
const handleTableColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = page.table.columns;
|
||||
const existingColumns = table.columns;
|
||||
|
||||
if (values.length === 0) {
|
||||
return setTable({
|
||||
columns: [],
|
||||
data: {
|
||||
columns: [],
|
||||
},
|
||||
key: pageKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,20 +267,20 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
if (values.length > existingColumns.length) {
|
||||
const newColumn = { column: values[values.length - 1], width: 100 };
|
||||
|
||||
setTable({ columns: [...existingColumns, newColumn] });
|
||||
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
|
||||
} 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 });
|
||||
setTable({ data: { columns: newColumns }, key: pageKey });
|
||||
}
|
||||
|
||||
return tableRef.current?.api.sizeColumnsToFit();
|
||||
};
|
||||
|
||||
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTable({ autoFit: e.currentTarget.checked });
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
@@ -279,8 +289,8 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries(queryKeys.albumArtists.list(server?.id || ''));
|
||||
handleFilterChange(filters);
|
||||
}, [filters, handleFilterChange, queryClient, server?.id]);
|
||||
handleFilterChange(filter);
|
||||
}, [filter, handleFilterChange, queryClient, server?.id]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
@@ -301,14 +311,14 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${filter.name}`}
|
||||
$isActive={filter.value === filters.sortBy}
|
||||
value={filter.value}
|
||||
key={`filter-${f.name}`}
|
||||
$isActive={f.value === filter.sortBy}
|
||||
value={f.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{filter.name}
|
||||
{f.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
@@ -324,7 +334,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
sortOrderLabel
|
||||
) : (
|
||||
<>
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
{filter.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
@@ -348,7 +358,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filters.musicFolderId === folder.id}
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
value={folder.id}
|
||||
onClick={handleSetMusicFolder}
|
||||
>
|
||||
@@ -392,28 +402,28 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.CARD}
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.POSTER}
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE}
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={page.display === ListDisplayType.TABLE_PAGINATED}
|
||||
$isActive={display === ListDisplayType.TABLE_PAGINATED}
|
||||
value={ListDisplayType.TABLE_PAGINATED}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
@@ -422,20 +432,25 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
<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}
|
||||
/>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<Slider
|
||||
defaultValue={grid?.itemsPerRow}
|
||||
label={null}
|
||||
max={10}
|
||||
min={2}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
) : (
|
||||
<Slider
|
||||
defaultValue={table.rowHeight}
|
||||
label={null}
|
||||
max={100}
|
||||
min={30}
|
||||
onChange={debouncedHandleItemSize}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu.Item>
|
||||
{(page.display === ListDisplayType.TABLE ||
|
||||
page.display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
{(display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
@@ -447,14 +462,14 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={ALBUMARTIST_TABLE_COLUMNS}
|
||||
defaultValue={page.table?.columns.map((column) => column.column)}
|
||||
defaultValue={table?.columns.map((column) => column.column)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={page.table.autoFit}
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -7,24 +7,19 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
PageHeader,
|
||||
Paper,
|
||||
SearchInput,
|
||||
SpinnerIcon,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumArtistListFilter,
|
||||
useAlbumArtistListStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumArtistFilters,
|
||||
useSetAlbumArtistTablePagination,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -39,12 +34,11 @@ export const AlbumArtistListHeader = ({
|
||||
}: AlbumArtistListHeaderProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setFilter = useSetAlbumArtistFilters();
|
||||
const page = useAlbumArtistListStore();
|
||||
const { pageKey } = useAlbumArtistListContext();
|
||||
const { display, filter } = useAlbumArtistListStore();
|
||||
const { setFilter, setTablePagination } = useListStoreActions();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const setPagination = useSetAlbumArtistTablePagination();
|
||||
|
||||
const fetch = useCallback(
|
||||
async (startIndex: number, limit: number, filters: AlbumArtistListFilter) => {
|
||||
const queryKey = queryKeys.albumArtists.list(server?.id || '', {
|
||||
@@ -75,10 +69,7 @@ export const AlbumArtistListHeader = ({
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
async (filters: AlbumArtistListFilter) => {
|
||||
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;
|
||||
@@ -117,8 +108,8 @@ export const AlbumArtistListHeader = ({
|
||||
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: pageKey });
|
||||
}
|
||||
} else {
|
||||
gridRef.current?.scrollTo(0);
|
||||
@@ -133,13 +124,16 @@ export const AlbumArtistListHeader = ({
|
||||
gridRef.current?.setItemData(data.items);
|
||||
}
|
||||
},
|
||||
[page.display, tableRef, setPagination, server, queryClient, gridRef, fetch],
|
||||
[display, tableRef, server, queryClient, setTablePagination, pageKey, 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: pageKey,
|
||||
}) as AlbumArtistListFilter;
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
@@ -155,30 +149,25 @@ export const AlbumArtistListHeader = ({
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Album Artists</LibraryHeaderBar.Title>
|
||||
<Paper
|
||||
fw="600"
|
||||
px="1rem"
|
||||
py="0.3rem"
|
||||
radius="sm"
|
||||
>
|
||||
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
|
||||
</Paper>
|
||||
<LibraryHeaderBar.Badge isLoading={itemCount === null || itemCount === undefined}>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={page.filter.searchTerm}
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<Paper p="1rem">
|
||||
<FilterBar>
|
||||
<AlbumArtistListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</Paper>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ListKey } from '/@/renderer/store';
|
||||
|
||||
export const AlbumArtistDetailSongListContext = createContext<{ id?: string; pageKey: ListKey }>({
|
||||
pageKey: 'albumArtist',
|
||||
});
|
||||
|
||||
export const useAlbumArtistDetailSongListContext = () => {
|
||||
const ctxValue = useContext(AlbumArtistDetailSongListContext);
|
||||
return ctxValue;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ListKey } from '/@/renderer/store';
|
||||
|
||||
export const AlbumArtistListContext = createContext<{ id?: string; pageKey: ListKey }>({
|
||||
pageKey: 'albumArtist',
|
||||
});
|
||||
|
||||
export const useAlbumArtistListContext = () => {
|
||||
const ctxValue = useContext(AlbumArtistListContext);
|
||||
return ctxValue;
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { AlbumListSort, SortOrder, SongListSort } from '/@/renderer/api/types';
|
||||
import { VirtualGridContainer } from '/@/renderer/components';
|
||||
import { useAlbumList } from '/@/renderer/features/albums';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useSongList } from '/@/renderer/features/songs';
|
||||
// import { useSongListStore } from '/@/renderer/store';
|
||||
// import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
// import { Play } from '/@/renderer/types';
|
||||
// import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
|
||||
const AlbumArtistDetailDiscographyRoute = () => {
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
// const albumArtistQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
const albumsQuery = useAlbumList({
|
||||
jfParams: { artistIds: albumArtistId },
|
||||
ndParams: { artist_id: albumArtistId },
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
});
|
||||
|
||||
const songsQuery = useSongList(
|
||||
{
|
||||
albumIds: albumsQuery.data?.items?.map((album) => album.id),
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
enabled: !albumsQuery.isLoading,
|
||||
},
|
||||
);
|
||||
|
||||
// const page = useSongListStore();
|
||||
|
||||
// const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
if (albumsQuery.isLoading || songsQuery.isLoading) return null;
|
||||
|
||||
// const handlePlay = (play: Play, data: any[]) => {
|
||||
// handlePlayQueueAdd?.({
|
||||
// byData: data,
|
||||
// play,
|
||||
// });
|
||||
// };
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
{/* <AlbumArtistDiscographyHeader />
|
||||
<PageHeader>
|
||||
<Group
|
||||
position="apart"
|
||||
w="100%"
|
||||
>
|
||||
{albumArtistQuery?.data?.name || ''}
|
||||
<Group spacing="xs">
|
||||
<Button
|
||||
compact
|
||||
radius="xl"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiListUnordered size="1.5em" />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
radius="xl"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiLayoutGridFill size="1.5em" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</PageHeader> *
|
||||
<AlbumArtistDiscographyDetailList /> */}
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumArtistDetailDiscographyRoute;
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { VirtualGridContainer } from '/@/renderer/components';
|
||||
import { AlbumArtistDetailSongListContent } from '/@/renderer/features/artists/components/album-artist-detail-song-list-content';
|
||||
import { AlbumArtistDetailSongListHeader } from '/@/renderer/features/artists/components/album-artist-detail-song-list-header';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useSongList } from '/@/renderer/features/songs';
|
||||
import { SongListFilter } from '/@/renderer/store';
|
||||
|
||||
const AlbumArtistDetailSongListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
const [filter, setFilter] = useSetState<SongListFilter>({
|
||||
artistIds: [albumArtistId],
|
||||
sortBy: SongListSort.ALBUM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
});
|
||||
|
||||
const itemCountCheck = useSongList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
);
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
? undefined
|
||||
: itemCountCheck.data?.totalRecordCount;
|
||||
|
||||
if (detailQuery.isLoading) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumArtistDetailSongListHeader
|
||||
filter={filter}
|
||||
itemCount={itemCount}
|
||||
setFilter={setFilter}
|
||||
tableRef={tableRef}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<AlbumArtistDetailSongListContent
|
||||
filter={filter}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</VirtualGridContainer>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumArtistDetailSongListRoute;
|
||||
@@ -1,25 +1,21 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { VirtualGridContainer } from '/@/renderer/components';
|
||||
import { AlbumArtistDetailTopSongsListContent } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-content';
|
||||
import { AlbumArtistDetailTopSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-header';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
|
||||
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 itemCount = topSongsQuery?.data?.items?.length || 0;
|
||||
@@ -28,17 +24,15 @@ const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={topSongsQuery?.data?.items || []}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<AlbumArtistDetailTopSongsListContent
|
||||
data={topSongsQuery?.data?.items || []}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</VirtualGridContainer>
|
||||
<AlbumArtistDetailTopSongsListHeader
|
||||
data={topSongsQuery?.data?.items || []}
|
||||
itemCount={itemCount}
|
||||
title={detailQuery?.data?.name || 'Unknown'}
|
||||
/>
|
||||
<AlbumArtistDetailTopSongsListContent
|
||||
data={topSongsQuery?.data?.items || []}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { VirtualGridContainer, VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { useAlbumArtistListStore } from '/@/renderer/store';
|
||||
import { generatePageKey, useAlbumArtistListFilter } from '/@/renderer/store';
|
||||
import { AlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const page = useAlbumArtistListStore();
|
||||
const filters = page.filter;
|
||||
const pageKey = generatePageKey('albumArtist', undefined);
|
||||
|
||||
const albumArtistListFilter = useAlbumArtistListFilter({ id: undefined, key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumArtistList(
|
||||
{
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
...albumArtistListFilter,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -32,7 +34,7 @@ const AlbumArtistListRoute = () => {
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<VirtualGridContainer>
|
||||
<AlbumArtistListContext.Provider value={{ id: undefined, pageKey }}>
|
||||
<AlbumArtistListHeader
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
@@ -42,7 +44,7 @@ const AlbumArtistListRoute = () => {
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</VirtualGridContainer>
|
||||
</AlbumArtistListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ export const PlayQueueListControls = ({ type, tableRef }: PlayQueueListOptionsPr
|
||||
<Group>
|
||||
<Popover
|
||||
position="top-end"
|
||||
transition="fade"
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
useDefaultQueue,
|
||||
usePlayerControls,
|
||||
usePreviousSong,
|
||||
useQueueControls,
|
||||
} from '/@/renderer/store';
|
||||
@@ -53,6 +54,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
const tableConfig = useTableSettings(type);
|
||||
const [gridApi, setGridApi] = useState<AgGridReactType | undefined>();
|
||||
const playerType = usePlayerType();
|
||||
const { play } = usePlayerControls();
|
||||
|
||||
useEffect(() => {
|
||||
if (tableRef.current) {
|
||||
@@ -79,6 +81,8 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
}
|
||||
|
||||
play();
|
||||
};
|
||||
|
||||
const handleDragStart = () => {
|
||||
@@ -160,7 +164,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const rowClassRules = useMemo<RowClassRules>(() => {
|
||||
const rowClassRules = useMemo<RowClassRules | undefined>(() => {
|
||||
return {
|
||||
'current-song': (params) => {
|
||||
return params.data.uniqueId === currentSong?.uniqueId;
|
||||
@@ -205,11 +209,13 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
|
||||
rowDragMultiRow
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
deselectOnClickOutside={type === 'fullScreen'}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
rowBuffer={50}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={queue}
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
suppressCellFocus={type === 'fullScreen'}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onCellDoubleClicked={handleDoubleClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
|
||||
@@ -5,16 +5,24 @@ import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queu
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { PageHeader, Paper, VirtualGridContainer } from '/@/renderer/components';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
export const SidebarPlayQueue = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
return (
|
||||
<VirtualGridContainer>
|
||||
<Stack>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)" />
|
||||
</Stack>
|
||||
<Paper>
|
||||
{windowBarStyle === Platform.WEB && (
|
||||
<Stack mr={windowBarStyle === Platform.WEB ? '130px' : undefined}>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)" />
|
||||
</Stack>
|
||||
)}
|
||||
<Paper
|
||||
display={windowBarStyle !== Platform.WEB ? 'flex' : undefined}
|
||||
h={windowBarStyle !== Platform.WEB ? '65px' : undefined}
|
||||
>
|
||||
<PlayQueueListControls
|
||||
tableRef={queueRef}
|
||||
type="sideQueue"
|
||||
|
||||
@@ -69,7 +69,7 @@ export const CenterControls = ({ playersRef }: CenterControlsProps) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const currentSong = useCurrentSong();
|
||||
const songDuration = currentSong?.duration;
|
||||
const skip = useSettingsStore((state) => state.player.skipButtons);
|
||||
const skip = useSettingsStore((state) => state.general.skipButtons);
|
||||
const playerType = usePlayerType();
|
||||
const player1 = playersRef?.current?.player1;
|
||||
const player2 = playersRef?.current?.player2;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Flex, Stack, Group, Center } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { AnimatePresence, HTMLMotionProps, motion, Variants } from 'framer-motion';
|
||||
import { useEffect } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { Badge, Text, TextTitle } from '/@/renderer/components';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { PlayerData, usePlayerData, usePlayerStore } from '/@/renderer/store';
|
||||
|
||||
const Image = styled(motion.img)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(motion.div)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 65%;
|
||||
aspect-ratio: 1/1;
|
||||
`;
|
||||
|
||||
const imageVariants: Variants = {
|
||||
closed: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'linear',
|
||||
},
|
||||
},
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { isOpen } = custom;
|
||||
return {
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: 'linear',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const scaleImageUrl = (url?: string | null) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, '&size=800')
|
||||
.replace(/\?width=\d+/, '?width=800')
|
||||
.replace(/&height=\d+/, '&height=800');
|
||||
};
|
||||
|
||||
const ImageWithPlaceholder = ({ ...props }: HTMLMotionProps<'img'>) => {
|
||||
if (!props.src) {
|
||||
return (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size="25%"
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <Image {...props} />;
|
||||
};
|
||||
|
||||
export const FullScreenPlayerImage = () => {
|
||||
const { queue } = usePlayerData();
|
||||
const currentSong = queue.current;
|
||||
const background = useFastAverageColor(queue.current?.imageUrl, true, 'dominant');
|
||||
const imageKey = `image-${background}`;
|
||||
|
||||
const [imageState, setImageState] = useSetState({
|
||||
bottomImage: scaleImageUrl(queue.next?.imageUrl),
|
||||
current: 0,
|
||||
topImage: scaleImageUrl(queue.current?.imageUrl),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.actions.getPlayerData().queue],
|
||||
(state) => {
|
||||
const isTop = imageState.current === 0;
|
||||
const queue = state[1] as PlayerData['queue'];
|
||||
|
||||
const currentImageUrl = scaleImageUrl(queue.current?.imageUrl);
|
||||
const nextImageUrl = scaleImageUrl(queue.next?.imageUrl);
|
||||
|
||||
setImageState({
|
||||
bottomImage: isTop ? currentImageUrl : nextImageUrl,
|
||||
current: isTop ? 1 : 0,
|
||||
topImage: isTop ? nextImageUrl : currentImageUrl,
|
||||
});
|
||||
},
|
||||
{ equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id },
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
};
|
||||
}, [imageState, queue, setImageState]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
className="full-screen-player-image-container"
|
||||
direction="column"
|
||||
justify="flex-start"
|
||||
p="1rem"
|
||||
sx={{ flex: 0.5, gap: '1rem' }}
|
||||
>
|
||||
<ImageContainer>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{imageState.current === 0 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 0 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.topImage || ''}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
{imageState.current === 1 && (
|
||||
<ImageWithPlaceholder
|
||||
key={imageKey}
|
||||
animate="open"
|
||||
className="full-screen-player-image"
|
||||
custom={{ isOpen: imageState.current === 1 }}
|
||||
draggable={false}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
placeholder="var(--placeholder-bg)"
|
||||
src={imageState.bottomImage || ''}
|
||||
variants={imageVariants}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ImageContainer>
|
||||
<Stack
|
||||
className="full-screen-player-image-metadata"
|
||||
spacing="sm"
|
||||
>
|
||||
<TextTitle
|
||||
align="center"
|
||||
order={1}
|
||||
overflow="hidden"
|
||||
w="100%"
|
||||
weight={900}
|
||||
>
|
||||
{currentSong?.name}
|
||||
</TextTitle>
|
||||
<TextTitle
|
||||
$link
|
||||
align="center"
|
||||
component={Link}
|
||||
order={2}
|
||||
overflow="hidden"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong?.albumId || '',
|
||||
})}
|
||||
w="100%"
|
||||
weight={600}
|
||||
>
|
||||
{currentSong?.album}{' '}
|
||||
</TextTitle>
|
||||
{currentSong?.artists?.map((artist, index) => (
|
||||
<TextTitle
|
||||
key={`fs-artist-${artist.id}`}
|
||||
align="center"
|
||||
order={4}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Text
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 0.5rem',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
$link
|
||||
component={Link}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
weight={600}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
</TextTitle>
|
||||
))}
|
||||
<Group position="center">
|
||||
{currentSong?.container && (
|
||||
<Badge size="lg">
|
||||
{currentSong?.container} {currentSong?.bitRate}
|
||||
</Badge>
|
||||
)}
|
||||
{currentSong?.releaseYear && <Badge size="lg">{currentSong?.releaseYear}</Badge>}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Stack, Group, Center, Box } from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Button, TextTitle } from '/@/renderer/components';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store/full-screen-player.store';
|
||||
|
||||
const QueueContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.ag-theme-alpine-dark {
|
||||
--ag-header-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
--ag-odd-row-background-color: rgba(0, 0, 0, 0%) !important;
|
||||
}
|
||||
|
||||
.ag-header {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActiveTabIndicator = styled(motion.div)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--main-fg);
|
||||
`;
|
||||
|
||||
export const FullScreenPlayerQueue = () => {
|
||||
const { activeTab } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const headerItems = [
|
||||
{
|
||||
active: activeTab === 'queue',
|
||||
icon: <RiFileMusicLine size="1.5rem" />,
|
||||
label: 'Up Next',
|
||||
onClick: () => setStore({ activeTab: 'queue' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'related',
|
||||
icon: <HiOutlineQueueList size="1.5rem" />,
|
||||
label: 'Related',
|
||||
onClick: () => setStore({ activeTab: 'related' }),
|
||||
},
|
||||
{
|
||||
active: activeTab === 'lyrics',
|
||||
icon: <RiFileTextLine size="1.5rem" />,
|
||||
label: 'Lyrics',
|
||||
onClick: () => setStore({ activeTab: 'lyrics' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="full-screen-player-queue-container"
|
||||
maw="100%"
|
||||
sx={{ flex: 0.5 }}
|
||||
>
|
||||
<Group
|
||||
grow
|
||||
align="center"
|
||||
position="center"
|
||||
>
|
||||
{headerItems.map((item) => (
|
||||
<Box pos="relative">
|
||||
<Button
|
||||
fullWidth
|
||||
uppercase
|
||||
fw="600"
|
||||
pos="relative"
|
||||
size="lg"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
color: item.active
|
||||
? 'var(--main-fg) !important'
|
||||
: 'var(--main-fg-secondary) !important',
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
variant="subtle"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{item.active ? <ActiveTabIndicator layoutId="underline" /> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueContainer>
|
||||
<PlayQueue type="fullScreen" />
|
||||
</QueueContainer>
|
||||
) : activeTab === 'related' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : activeTab === 'lyrics' ? (
|
||||
<Center>
|
||||
<Group>
|
||||
<RiInformationFill size="2rem" />
|
||||
<TextTitle
|
||||
order={3}
|
||||
weight={700}
|
||||
>
|
||||
COMING SOON
|
||||
</TextTitle>
|
||||
</Group>
|
||||
</Center>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { Group } from '@mantine/core';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
|
||||
import { useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Option, Popover, Switch, TableConfigDropdown } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
|
||||
const Container = styled(motion.div)`
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
const ResponsiveContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
max-width: 2560px;
|
||||
margin-top: 70px;
|
||||
|
||||
.full-screen-player-image {
|
||||
max-height: calc(35vh - 90px);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1080px) {
|
||||
flex-direction: row;
|
||||
|
||||
.full-screen-player-image {
|
||||
max-height: calc(70vh - 90px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 800px) and (min-width: 1080px) {
|
||||
.full-screen-player-image {
|
||||
max-height: calc(50vh - 90px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(20, 21, 23, 40%), var(--main-bg));
|
||||
`;
|
||||
|
||||
const Controls = () => {
|
||||
const { dynamicBackground, expanded } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const handleToggleFullScreenPlayer = () => {
|
||||
setStore({ expanded: !expanded });
|
||||
};
|
||||
|
||||
useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
p="1rem"
|
||||
pos="absolute"
|
||||
spacing="sm"
|
||||
sx={{
|
||||
left: 0,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Minimize' }}
|
||||
variant="subtle"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
<RiArrowDownSLine size="2rem" />
|
||||
</Button>
|
||||
<Popover position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Line size="1.5rem" />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>Dynamic Background</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicBackground: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<TableConfigDropdown type="fullScreen" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const containerVariants: Variants = {
|
||||
closed: {
|
||||
height: 'calc(100vh - 90px)',
|
||||
position: 'absolute',
|
||||
top: '100vh',
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: -100,
|
||||
},
|
||||
open: (custom) => {
|
||||
const { dynamicBackground, background } = custom;
|
||||
return {
|
||||
background: dynamicBackground ? background : 'var(--main-bg)',
|
||||
height: 'calc(100vh - 90px)',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transition: {
|
||||
background: {
|
||||
duration: 1,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
delay: 0.1,
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
width: '100vw',
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const FullScreenPlayer = () => {
|
||||
const { dynamicBackground } = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
|
||||
const location = useLocation();
|
||||
const isOpenedRef = useRef<boolean | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isOpenedRef.current !== null) {
|
||||
setStore({ expanded: false });
|
||||
}
|
||||
|
||||
isOpenedRef.current = true;
|
||||
}, [location, setStore]);
|
||||
|
||||
const currentSong = useCurrentSong();
|
||||
const background = useFastAverageColor(currentSong?.imageUrl, true, 'dominant');
|
||||
|
||||
return (
|
||||
<Container
|
||||
animate="open"
|
||||
custom={{ background, dynamicBackground }}
|
||||
exit="closed"
|
||||
initial="closed"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Controls />
|
||||
{dynamicBackground && <BackgroundImageOverlay />}
|
||||
<ResponsiveContainer>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
|
||||
@@ -6,7 +6,13 @@ import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useAppStore,
|
||||
useCurrentSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
useFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
@@ -35,10 +41,11 @@ const MetadataStack = styled(motion.div)`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Image = styled(motion(Link))`
|
||||
const Image = styled(motion.div)`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 100%));
|
||||
background-color: var(--placeholder-bg);
|
||||
filter: drop-shadow(0 5px 6px rgb(0, 0, 0, 50%));
|
||||
|
||||
${fadeIn};
|
||||
animation: fadein 0.2s ease-in-out;
|
||||
@@ -74,7 +81,9 @@ const LineItem = styled.div<{ $secondary?: boolean }>`
|
||||
`;
|
||||
|
||||
export const LeftControls = () => {
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const hideImage = useAppStore((state) => state.sidebar.image);
|
||||
const currentSong = useCurrentSong();
|
||||
const title = currentSong?.name;
|
||||
@@ -87,6 +96,16 @@ export const LeftControls = () => {
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const handleToggleFullScreenPlayer = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const handleToggleSidebarImage = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setSideBar({ image: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
<LayoutGroup>
|
||||
@@ -101,8 +120,9 @@ export const LeftControls = () => {
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
role="button"
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
>
|
||||
{currentSong?.imageUrl ? (
|
||||
<PlayerbarImage
|
||||
@@ -133,10 +153,7 @@ export const LeftControls = () => {
|
||||
sx={{ position: 'absolute', right: 2, top: 2 }}
|
||||
tooltip={{ label: 'Expand', openDelay: 500 }}
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSidebar({ image: true });
|
||||
}}
|
||||
onClick={handleToggleSidebarImage}
|
||||
>
|
||||
<RiArrowUpSLine
|
||||
color="white"
|
||||
|
||||
@@ -56,7 +56,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = useRef<any>();
|
||||
const settings = useSettingsStore((state) => state.player);
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
const player2 = usePlayer2Data();
|
||||
|
||||
@@ -28,7 +28,7 @@ export const RightControls = () => {
|
||||
const muted = useMuted();
|
||||
const server = useCurrentServer();
|
||||
const currentSong = useCurrentSong();
|
||||
const { setSidebar } = useAppStoreActions();
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls();
|
||||
|
||||
@@ -145,7 +145,7 @@ export const RightControls = () => {
|
||||
icon={<HiOutlineQueueList size="1.1rem" />}
|
||||
tooltip={{ label: 'View queue', openDelay: 500 }}
|
||||
variant="secondary"
|
||||
onClick={() => setSidebar({ rightExpanded: !isQueueExpanded })}
|
||||
onClick={() => setSideBar({ rightExpanded: !isQueueExpanded })}
|
||||
/>
|
||||
<Group
|
||||
noWrap
|
||||
|
||||
@@ -25,7 +25,7 @@ const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const { playersRef } = args;
|
||||
|
||||
const settings = useSettingsStore((state) => state.player);
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const currentPlayer = useCurrentPlayer();
|
||||
const { setShuffle, setRepeat, play, pause, previous, next, setCurrentIndex, autoNext } =
|
||||
usePlayerControls();
|
||||
|
||||
@@ -168,6 +168,20 @@ export const useHandlePlayQueueAdd = () => {
|
||||
if (!songs) return toast.warn({ message: 'No songs found' });
|
||||
|
||||
const playerData = usePlayerStore.getState().actions.addToQueue(songs, options.play);
|
||||
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
if (options.play === Play.NEXT || options.play === Play.LAST) {
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
|
||||
if (options.play === Play.NOW) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
play();
|
||||
|
||||
mpris?.updateSong({
|
||||
currentTime: usePlayerStore.getState().current.time,
|
||||
repeat: usePlayerStore.getState().repeat,
|
||||
@@ -176,21 +190,6 @@ export const useHandlePlayQueueAdd = () => {
|
||||
status: 'Playing',
|
||||
});
|
||||
|
||||
if (options.play === Play.NEXT || options.play === Play.LAST) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueueNext(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.play === Play.NOW) {
|
||||
if (playerType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData);
|
||||
mpvPlayer.play();
|
||||
}
|
||||
|
||||
play();
|
||||
}
|
||||
|
||||
// if (fetchId) {
|
||||
// toast.update({
|
||||
// autoClose: 1000,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const useRightControls = () => {
|
||||
}, []);
|
||||
|
||||
const handleVolumeSlider = (e: number) => {
|
||||
mpvPlayer.volume(e);
|
||||
mpvPlayer?.volume(e);
|
||||
setVolume(e);
|
||||
};
|
||||
|
||||
@@ -51,13 +51,13 @@ export const useRightControls = () => {
|
||||
}
|
||||
}
|
||||
|
||||
mpvPlayer.volume(volumeToSet);
|
||||
mpvPlayer?.volume(volumeToSet);
|
||||
setVolume(volumeToSet);
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
setMuted(!muted);
|
||||
mpvPlayer.mute();
|
||||
mpvPlayer?.mute();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { QueueSong, ServerType } from '/@/renderer/api/types';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerSettings } from '/@/renderer/store/settings.store';
|
||||
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
/*
|
||||
@@ -49,7 +49,7 @@ const checkScrobbleConditions = (args: {
|
||||
|
||||
export const useScrobble = () => {
|
||||
const status = useCurrentStatus();
|
||||
const scrobbleSettings = usePlayerSettings().scrobble;
|
||||
const scrobbleSettings = usePlaybackSettings().scrobble;
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const sendScrobble = useSendScrobble();
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
id={playlistId}
|
||||
pageKey={playlistId}
|
||||
pagination={pagination}
|
||||
setIdPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
|
||||
@@ -161,7 +161,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
|
||||
if (page.display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[tableRef, page.display, server, playlistId, queryClient, setPagination],
|
||||
|
||||
@@ -116,9 +116,11 @@ export const PlaylistListContent = ({ tableRef, itemCount }: PlaylistListContent
|
||||
}
|
||||
|
||||
setPagination({
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
[isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination],
|
||||
@@ -217,6 +219,7 @@ export const PlaylistListContent = ({ tableRef, itemCount }: PlaylistListContent
|
||||
>
|
||||
{page.display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey=""
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
tableRef={tableRef}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFilter
|
||||
tableRef.current?.api.setDatasource(dataSource);
|
||||
tableRef.current?.api.purgeInfiniteCache();
|
||||
tableRef.current?.api.ensureIndexVisible(0, 'top');
|
||||
setPagination({ currentPage: 0 });
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
},
|
||||
[page.filter, queryClient, server, setPagination, tableRef],
|
||||
);
|
||||
@@ -147,9 +147,9 @@ export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFilter
|
||||
|
||||
if (display === ListDisplayType.TABLE) {
|
||||
tableRef.current?.api.paginationSetPageSize(tableRef.current.props.infiniteInitialRowCount);
|
||||
setPagination({ currentPage: 0 });
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
} else if (display === ListDisplayType.TABLE_PAGINATED) {
|
||||
setPagination({ currentPage: 0 });
|
||||
setPagination({ data: { currentPage: 0 } });
|
||||
}
|
||||
},
|
||||
[page, setPage, setPagination, tableRef],
|
||||
|
||||
@@ -13,7 +13,7 @@ import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playli
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
|
||||
import { Button, Paper, Text, toast, VirtualGridContainer } from '/@/renderer/components';
|
||||
import { Button, Paper, Text, toast } from '/@/renderer/components';
|
||||
import { SaveAsPlaylistForm } from '/@/renderer/features/playlists/components/save-as-playlist-form';
|
||||
import { useCurrentServer, usePlaylistDetailStore } from '/@/renderer/store';
|
||||
import { PlaylistSongListQuery, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
@@ -162,63 +162,61 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
|
||||
<VirtualGridContainer>
|
||||
<PlaylistDetailSongListHeader
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<AnimatePresence
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
initial={false}
|
||||
>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<motion.div
|
||||
animate="animate"
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
variants={smartPlaylistVariants}
|
||||
<PlaylistDetailSongListHeader
|
||||
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<AnimatePresence
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
initial={false}
|
||||
>
|
||||
{(isSmartPlaylist || showQueryBuilder) && (
|
||||
<motion.div
|
||||
animate="animate"
|
||||
custom={{ isQueryBuilderExpanded }}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
variants={smartPlaylistVariants}
|
||||
>
|
||||
<Paper
|
||||
h="100%"
|
||||
pos="relative"
|
||||
w="100%"
|
||||
>
|
||||
<Paper
|
||||
h="100%"
|
||||
pos="relative"
|
||||
w="100%"
|
||||
<Group
|
||||
pt="1rem"
|
||||
px="1rem"
|
||||
>
|
||||
<Group
|
||||
pt="1rem"
|
||||
px="1rem"
|
||||
<Button
|
||||
compact
|
||||
variant="default"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
variant="default"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
{isQueryBuilderExpanded ? (
|
||||
<RiArrowUpSLine size={20} />
|
||||
) : (
|
||||
<RiArrowDownSLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Text>Query Editor</Text>
|
||||
</Group>
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
isSaving={createPlaylistMutation?.isLoading}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
query={detailQuery?.data?.rules}
|
||||
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
|
||||
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
/>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
</VirtualGridContainer>
|
||||
{isQueryBuilderExpanded ? (
|
||||
<RiArrowUpSLine size={20} />
|
||||
) : (
|
||||
<RiArrowDownSLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
<Text>Query Editor</Text>
|
||||
</Group>
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(detailQuery?.data?.rules)}
|
||||
isSaving={createPlaylistMutation?.isLoading}
|
||||
limit={detailQuery?.data?.rules?.limit}
|
||||
query={detailQuery?.data?.rules}
|
||||
sortBy={detailQuery?.data?.rules?.sort || SongListSort.ALBUM}
|
||||
sortOrder={detailQuery?.data?.rules?.order || 'asc'}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
/>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<PlaylistDetailSongListContent tableRef={tableRef} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,8 +45,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitDisabled =
|
||||
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
|
||||
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = AUTH_FUNCTIONS[values.type];
|
||||
|
||||