mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
Compare commits
6 commits
8bc45cda3b
...
21e5a4dfd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e5a4dfd2 | |||
| 4193dd36a1 | |||
| 59e94318bb | |||
| bf5e7bc774 | |||
| 228fc8e82b | |||
| 7a12e4657f |
43 changed files with 2045 additions and 207 deletions
119
README.md
119
README.md
|
|
@ -2,17 +2,21 @@
|
|||
|
||||
# Feishin
|
||||
|
||||
> **⚠️ Fork Notice**: This is a fork of the original [Feishin](https://github.com/jeffvli/feishin) project. I created this fork primarily to add folder view functionality that the original repo wasn't able to accommodate, along with some other features I found useful. While I'll try to keep this fork in sync with the original repo, that's not the primary focus. Feishin was pretty much perfect for me - I just missed the folder view feature. That said, I'll fix any obvious bugs I encounter, but for full support and the most up-to-date version, please stay with the original repository.
|
||||
>
|
||||
> **Note**: I primarily use Linux desktop, so only Linux issues can be addressed if needed.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/jeffvli/feishin/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen"
|
||||
<a href="https://github.com/antebudimir/feishin/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/antebudimir/feishin?style=flat-square&color=brightgreen"
|
||||
alt="License">
|
||||
</a>
|
||||
<a href="https://github.com/jeffvli/feishin/releases">
|
||||
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
|
||||
<a href="https://github.com/antebudimir/feishin/releases">
|
||||
<img src="https://img.shields.io/github/v/release/antebudimir/feishin?style=flat-square&color=blue"
|
||||
alt="Release">
|
||||
</a>
|
||||
<a href="https://github.com/jeffvli/feishin/releases">
|
||||
<img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange"
|
||||
<a href="https://github.com/antebudimir/feishin/releases">
|
||||
<img src="https://img.shields.io/github/downloads/antebudimir/feishin/total?style=flat-square&color=orange"
|
||||
alt="Downloads">
|
||||
</a>
|
||||
</p>
|
||||
|
|
@ -39,85 +43,16 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
|||
- [x] Scrobble playback to your server
|
||||
- [x] Smart playlist editor (Navidrome)
|
||||
- [x] Synchronized and unsynchronized lyrics support
|
||||
- [ ] [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_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.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>
|
||||
<a href="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/antebudimir/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Desktop (recommended)
|
||||
### Linux Desktop
|
||||
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
|
||||
|
||||
#### macOS Notes
|
||||
|
||||
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
|
||||
For media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.
|
||||
|
||||
#### Linux Notes
|
||||
|
||||
We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.
|
||||
|
||||
Simply run the installer like this:
|
||||
```sh
|
||||
dir=/your/application/directory
|
||||
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir"
|
||||
```
|
||||
|
||||
The script also has an option to add launch arguments to run Feishin in native Wayland mode. Note that this is experimental in Electron and therefore not officially supported. If you want to use it, run this instead:
|
||||
```sh
|
||||
dir=/your/application/directory
|
||||
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir" wayland-native
|
||||
```
|
||||
|
||||
It also provides a simple uninstall routine, removing the downloaded files:
|
||||
```sh
|
||||
dir=/your/application/directory
|
||||
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir" remove
|
||||
```
|
||||
|
||||
The entry should show up in your Application Launcher immediately. If it does not, simply log out, wait 10 seconds, and log back in. Your Desktop Environment may alternatively provide a way to reload entries.
|
||||
|
||||
### Web and Docker
|
||||
|
||||
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
|
||||
|
||||
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
|
||||
|
||||
```bash
|
||||
# Run the latest version
|
||||
docker run --name feishin -p 9180:9180 ghcr.io/jeffvli/feishin:latest
|
||||
|
||||
# Build the image locally
|
||||
docker build -t feishin .
|
||||
docker run --name feishin -p 9180:9180 feishin
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
To install via Docker Compose use the following snippit. This also works on Portainer.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
feishin:
|
||||
container_name: feishin
|
||||
image: 'ghcr.io/jeffvli/feishin:latest'
|
||||
environment:
|
||||
- SERVER_NAME=jellyfin # pre defined server name
|
||||
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
|
||||
- SERVER_TYPE=jellyfin # navidrome also works
|
||||
- SERVER_URL= # http://address:port
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- UMASK=002
|
||||
- TZ=America/Los_Angeles
|
||||
ports:
|
||||
- 9180:9180
|
||||
restart: unless-stopped
|
||||
```
|
||||
Download the [latest desktop client](https://github.com/antebudimir/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
|
@ -126,7 +61,7 @@ services:
|
|||
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
|
||||
|
||||
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
|
||||
- **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret score.
|
||||
- **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret score.
|
||||
|
||||
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
|
||||
|
||||
|
|
@ -145,16 +80,16 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
|
|||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [OpenSubsonic](https://opensubsonic.netlify.app/) compatible servers, such as...
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Ampache](https://ampache.org)
|
||||
- [Astiga](https://asti.ga/)
|
||||
- [Funkwhale](https://www.funkwhale.audio/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [LMS](https://github.com/epoupon/lms)
|
||||
- [Nextcloud Music](https://apps.nextcloud.com/apps/music)
|
||||
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||
- [Qm-Music](https://github.com/chenqimiao/qm-music)
|
||||
- More (?)
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Ampache](https://ampache.org)
|
||||
- [Astiga](https://asti.ga/)
|
||||
- [Funkwhale](https://www.funkwhale.audio/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [LMS](https://github.com/epoupon/lms)
|
||||
- [Nextcloud Music](https://apps.nextcloud.com/apps/music)
|
||||
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||
- [Qm-Music](https://github.com/chenqimiao/qm-music)
|
||||
- More (?)
|
||||
|
||||
### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux
|
||||
|
||||
|
|
@ -165,7 +100,7 @@ chmod 4755 chrome-sandbox
|
|||
sudo chown root:root chrome-sandbox
|
||||
```
|
||||
|
||||
Ubuntu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes.
|
||||
Ubuntu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see <https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20> for possible fixes.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -200,10 +135,6 @@ This project is built off of [electron-vite](https://github.com/alex8088/electro
|
|||
- `pnpm run lint:fix` - Lint the project and fix linting errors
|
||||
- `pnpm run i18next` - Generate i18n files
|
||||
|
||||
## Translation
|
||||
|
||||
This project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ linux:
|
|||
npmRebuild: false
|
||||
publish:
|
||||
provider: github
|
||||
owner: jeffvli
|
||||
owner: antebudimir
|
||||
repo: feishin
|
||||
channel: latest
|
||||
releaseType: draft
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "feishin",
|
||||
"version": "0.21.2",
|
||||
"version": "0.25.0-fork",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
|
|
|
|||
|
|
@ -403,6 +403,10 @@
|
|||
"visualizer": "visualizer",
|
||||
"noLyrics": "no lyrics found"
|
||||
},
|
||||
"folderList": {
|
||||
"title": "Folders",
|
||||
"description": "Browse music by folder structure"
|
||||
},
|
||||
"genreList": {
|
||||
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
|
||||
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
|
||||
|
|
@ -417,11 +421,14 @@
|
|||
"title": "commands"
|
||||
},
|
||||
"home": {
|
||||
"explore": "explore from your library",
|
||||
"explore": "discovery",
|
||||
"flashback": "flashback",
|
||||
"mostPlayed": "most played",
|
||||
"newlyAdded": "newly added releases",
|
||||
"newlyAdded": "recently added",
|
||||
"recentlyPlayed": "recently played",
|
||||
"recentlyReleased": "recently released",
|
||||
"starredAlbums": "starred albums",
|
||||
"starredTracks": "starred tracks",
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"itemDetail": {
|
||||
|
|
@ -596,6 +603,10 @@
|
|||
"enableAutoTranslation": "enable auto translation",
|
||||
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
|
||||
"enableRemote": "enable remote control server",
|
||||
"enableScanlineEffect_description": "enable the animated scanline visual effect across the application",
|
||||
"enableScanlineEffect": "enable scanline effect",
|
||||
"enableShimmerEffect_description": "enable the animated shimmer visual effect across the application",
|
||||
"enableShimmerEffect": "enable shimmer effect",
|
||||
"exitToTray_description": "exit the application to the system tray",
|
||||
"exitToTray": "exit to tray",
|
||||
"exportImportSettings_control_description": "export and import settings via JSON",
|
||||
|
|
|
|||
|
|
@ -281,6 +281,20 @@ export const controller: GeneralController = {
|
|||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
getFolderList(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getFolderList`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'getFolderList',
|
||||
server.type,
|
||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
||||
},
|
||||
getGenreList(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
|
|
|||
|
|
@ -386,6 +386,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||
|
||||
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
||||
},
|
||||
getFolderList: async () => {
|
||||
// Jellyfin doesn't have folder-based navigation like Subsonic
|
||||
// This would need to be implemented using Jellyfin's folder structure
|
||||
throw new Error('Folder browsing not supported for Jellyfin servers');
|
||||
},
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
|
@ -1085,6 +1090,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
};
|
||||
},
|
||||
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
|
|
|
|||
|
|
@ -353,6 +353,11 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||
getFolderList: async () => {
|
||||
// Navidrome supports Subsonic API, so this should work
|
||||
// But for now, delegate to Subsonic implementation
|
||||
throw new Error('Use Subsonic API endpoint for folder browsing on Navidrome servers');
|
||||
},
|
||||
getGenreList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
|
@ -716,6 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||
id: res.body.data.id,
|
||||
};
|
||||
},
|
||||
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
AlbumDetailQuery,
|
||||
AlbumListQuery,
|
||||
ArtistListQuery,
|
||||
FolderListQuery,
|
||||
GenreListQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsQuery,
|
||||
|
|
@ -158,6 +159,13 @@ export const queryKeys: Record<
|
|||
},
|
||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||
},
|
||||
folders: {
|
||||
list: (serverId: string, query?: FolderListQuery) => {
|
||||
if (query) return [serverId, 'folders', 'list', query] as const;
|
||||
return [serverId, 'folders', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'folders'] as const,
|
||||
},
|
||||
genres: {
|
||||
list: (serverId: string, query?: GenreListQuery) => {
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
|
|
|||
|
|
@ -100,6 +100,22 @@ export const contract = c.router({
|
|||
200: ssType._response.getGenres,
|
||||
},
|
||||
},
|
||||
getIndexes: {
|
||||
method: 'GET',
|
||||
path: 'getIndexes.view',
|
||||
query: ssType._parameters.getIndexes,
|
||||
responses: {
|
||||
200: ssType._response.getIndexes,
|
||||
},
|
||||
},
|
||||
getMusicDirectory: {
|
||||
method: 'GET',
|
||||
path: 'getMusicDirectory.view',
|
||||
query: ssType._parameters.getMusicDirectory,
|
||||
responses: {
|
||||
200: ssType._response.getMusicDirectory,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
|
|
|
|||
|
|
@ -620,6 +620,77 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||
'&c=Feishin'
|
||||
);
|
||||
},
|
||||
getFolderList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
// Check if this is a root music folder ID (single digit like '0', '1', etc.)
|
||||
// These IDs from getMusicFolders are NOT valid directory IDs
|
||||
// We need to use getIndexes instead to get the top-level content
|
||||
const isMusicFolderId = /^\d+$/.test(query.id);
|
||||
|
||||
if (isMusicFolderId) {
|
||||
const res = await ssApiClient(apiClientProps).getIndexes({
|
||||
query: {
|
||||
musicFolderId: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
|
||||
// Convert index entries to folder items
|
||||
const items: any[] = [];
|
||||
res.body.indexes?.index?.forEach((idx) => {
|
||||
idx.artist?.forEach((artist) => {
|
||||
items.push({
|
||||
id: artist.id.toString(),
|
||||
imageUrl: null,
|
||||
isDir: true,
|
||||
itemType: 'folder' as const,
|
||||
name: artist.name,
|
||||
serverId: apiClientProps.server?.id || 'unknown',
|
||||
serverType: 'subsonic' as const,
|
||||
title: artist.name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
items,
|
||||
name: 'Music',
|
||||
parent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// For actual directory IDs, use getMusicDirectory
|
||||
const requestQuery = {
|
||||
id: query.id,
|
||||
};
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getMusicDirectory({
|
||||
query: requestQuery,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Failed to get folder list: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
|
||||
const directory = res.body.directory;
|
||||
|
||||
const result = {
|
||||
id: directory.id.toString(),
|
||||
items:
|
||||
directory.child?.map((item) =>
|
||||
ssNormalize.folderItem(item, apiClientProps.server),
|
||||
) || [],
|
||||
name: directory.name,
|
||||
parent: directory.parent,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
getGenreList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ const utils = isElectron() ? window.api.utils : null;
|
|||
export const App = () => {
|
||||
const { mode, theme } = useAppTheme();
|
||||
const language = useSettingsStore((store) => store.general.language);
|
||||
const enableScanlineEffect = useSettingsStore((store) => store.general.enableScanlineEffect);
|
||||
const enableShimmerEffect = useSettingsStore((store) => store.general.enableShimmerEffect);
|
||||
|
||||
const { content, enabled } = useCssSettings();
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
|
|
@ -188,6 +190,24 @@ export const App = () => {
|
|||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableScanlineEffect) {
|
||||
document.body.classList.add('enable-scanline');
|
||||
document.documentElement.classList.add('enable-scanline');
|
||||
} else {
|
||||
document.body.classList.remove('enable-scanline');
|
||||
document.documentElement.classList.remove('enable-scanline');
|
||||
}
|
||||
|
||||
if (enableShimmerEffect) {
|
||||
document.body.classList.add('enable-shimmer');
|
||||
document.documentElement.classList.add('enable-shimmer');
|
||||
} else {
|
||||
document.body.classList.remove('enable-shimmer');
|
||||
document.documentElement.classList.remove('enable-shimmer');
|
||||
}
|
||||
}, [enableScanlineEffect, enableShimmerEffect]);
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={mode} theme={theme}>
|
||||
<Notifications
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@
|
|||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--theme-card-default-bg);
|
||||
border-radius: var(--theme-card-poster-radius);
|
||||
border: 2px solid var(--theme-orange-transparent-40);
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: var(--theme-shadow-orange-glow-soft);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
|
|
@ -39,6 +42,13 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-orange-transparent-70);
|
||||
box-shadow:
|
||||
var(--theme-shadow-orange-glow-medium),
|
||||
0 0 5px var(--theme-orange-transparent-30),
|
||||
0 0 5px var(--theme-orange-transparent-20);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
display: flex;
|
||||
grid-area: image;
|
||||
align-items: flex-end;
|
||||
|
||||
img {
|
||||
border-radius: var(--theme-radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
.info-column {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
z-index: 190;
|
||||
width: 100%;
|
||||
height: 65px;
|
||||
border-bottom: 2px solid var(--theme-orange-transparent-30);
|
||||
box-shadow: 0 4px 12px var(--theme-orange-transparent-10);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--theme-card-default-bg);
|
||||
border: 2px solid var(--theme-orange-transparent-40);
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: var(--theme-shadow-orange-glow-soft);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
|
|
@ -41,6 +46,12 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-orange-transparent-70);
|
||||
box-shadow:
|
||||
var(--theme-shadow-orange-glow-medium),
|
||||
0 0 5px var(--theme-orange-transparent-30),
|
||||
0 0 5px var(--theme-orange-transparent-20);
|
||||
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
@ -72,7 +83,6 @@
|
|||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
|||
{ divider: true, id: 'showDetails' },
|
||||
];
|
||||
|
||||
export const FOLDER_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
{ id: 'playNext' },
|
||||
{ divider: true, id: 'playShuffled' },
|
||||
{ divider: true, id: 'addToPlaylist' },
|
||||
];
|
||||
|
||||
export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||
{ id: 'play' },
|
||||
{ id: 'playLast' },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Album,
|
||||
AlbumArtist,
|
||||
Artist,
|
||||
FolderItem,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
Song,
|
||||
|
|
@ -72,7 +73,7 @@ export const useHandleGeneralContextMenu = (
|
|||
) => {
|
||||
const handleContextMenu = (
|
||||
e: any,
|
||||
data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[],
|
||||
data: Album[] | AlbumArtist[] | Artist[] | FolderItem[] | QueueSong[] | Song[],
|
||||
) => {
|
||||
if (!e) return;
|
||||
const clickEvent = e as MouseEvent;
|
||||
|
|
|
|||
32
src/renderer/features/folders/api/folder-api.ts
Normal file
32
src/renderer/features/folders/api/folder-api.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { FolderListQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const folderQueries = {
|
||||
list: (args: QueryHookArgs<FolderListQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getFolderList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.folders.list(args.serverId, args.query),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
musicFolders: (args: { options?: any; serverId: string }) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getMusicFolderList({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.musicFolders.list(args.serverId),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
};
|
||||
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal file
712
src/renderer/features/folders/components/folder-list-content.tsx
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { ActionIcon, Box, Breadcrumbs, Group, Stack, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { RiArrowLeftLine, RiFolderLine, RiMusicLine, RiPlayFill } from 'react-icons/ri';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import styles from '../routes/folder-list-route.module.css';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
|
||||
import { FOLDER_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { useFolderPath, useFolderStoreActions } from '/@/renderer/store/folder.store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import {
|
||||
FolderItem,
|
||||
FolderListResponse,
|
||||
LibraryItem,
|
||||
MusicFolderListResponse,
|
||||
ServerListItemWithCredential,
|
||||
Song,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface FolderListContentProps {
|
||||
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const FolderListContent = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
gridRef: _gridRef,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
itemCount: _itemCount,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
tableRef: _tableRef,
|
||||
}: FolderListContentProps) => {
|
||||
const server = useCurrentServer();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const folderId = searchParams.get('folderId');
|
||||
const folderActions = useFolderStoreActions();
|
||||
const { path } = useFolderPath();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const queryClient = useQueryClient();
|
||||
const [loadingFolderId, setLoadingFolderId] = useState<null | string>(null);
|
||||
const [emptyFolders, setEmptyFolders] = useState<Set<string>>(new Set());
|
||||
const [checkingEmptyFolders, setCheckingEmptyFolders] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Sync breadcrumb path with browser history state
|
||||
useEffect(() => {
|
||||
if (!folderId) {
|
||||
// At root level, reset path
|
||||
folderActions.resetPath();
|
||||
} else {
|
||||
// Try to restore path from history state
|
||||
const historyState = location.state as any;
|
||||
if (historyState?.breadcrumbPath) {
|
||||
folderActions.setPath(historyState.breadcrumbPath);
|
||||
} else {
|
||||
// No history state, reset path to avoid incorrect breadcrumbs
|
||||
folderActions.resetPath();
|
||||
}
|
||||
}
|
||||
}, [folderId, folderActions, location.state]);
|
||||
|
||||
const handleFolderContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.FOLDER,
|
||||
FOLDER_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const folderQuery = useQuery({
|
||||
enabled: !!folderId,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: folderId || '',
|
||||
},
|
||||
}),
|
||||
queryKey: ['folder', server?.id, folderId],
|
||||
});
|
||||
|
||||
const musicFoldersQuery = useQuery({
|
||||
enabled: !folderId,
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getMusicFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
}),
|
||||
queryKey: ['music-folders', server?.id],
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (folderId && folderQuery.data) {
|
||||
const data = folderQuery.data as FolderListResponse;
|
||||
return data.items || [];
|
||||
}
|
||||
if (!folderId && musicFoldersQuery.data) {
|
||||
const data = musicFoldersQuery.data as MusicFolderListResponse;
|
||||
return (data.items || []).map((folder) => ({
|
||||
id: folder.id,
|
||||
imageUrl: null,
|
||||
isDir: true,
|
||||
itemType: 'folder' as const,
|
||||
name: folder.name,
|
||||
serverId: server?.id || '',
|
||||
serverType: server?.type || 'subsonic',
|
||||
title: folder.name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [folderId, folderQuery.data, musicFoldersQuery.data, server]);
|
||||
|
||||
// Proactively check which folders are empty
|
||||
useEffect(() => {
|
||||
const checkEmptyFolders = async () => {
|
||||
if (!items || items.length === 0 || !folderId) return;
|
||||
|
||||
setCheckingEmptyFolders(true);
|
||||
const emptyFolderIds: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDir && !emptyFolders.has(item.id)) {
|
||||
try {
|
||||
// Fetch folder contents to check if it has any items
|
||||
const folderData = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: { id: item.id },
|
||||
}),
|
||||
queryKey: ['folder', server?.id, item.id],
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
// If folder has no items, mark it as empty
|
||||
if (!folderData.items || folderData.items.length === 0) {
|
||||
emptyFolderIds.push(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// On error, don't mark as empty (assume it might have content)
|
||||
console.warn(`Failed to check folder ${item.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyFolderIds.length > 0) {
|
||||
setEmptyFolders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
emptyFolderIds.forEach((id) => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
setCheckingEmptyFolders(false);
|
||||
};
|
||||
|
||||
checkEmptyFolders();
|
||||
}, [items, folderId, server, queryClient, emptyFolders]);
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(item: FolderItem) => {
|
||||
const newFolderId = item.id;
|
||||
const folderName = item.name || item.title || 'Unknown Folder';
|
||||
|
||||
// Update the path in the store
|
||||
folderActions.pushPath({ id: newFolderId, name: folderName });
|
||||
|
||||
// Navigate to the new folder with breadcrumb path in state
|
||||
const currentPath = path || [];
|
||||
navigate(`/library/folders?folderId=${newFolderId}`, {
|
||||
state: { breadcrumbPath: [...currentPath, { id: newFolderId, name: folderName }] },
|
||||
});
|
||||
},
|
||||
[navigate, folderActions, path],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (path && path.length > 0) {
|
||||
// Navigate to the previous folder in the path
|
||||
const previousFolder = path[path.length - 2]; // Second to last item
|
||||
if (previousFolder) {
|
||||
const newPath = path.slice(0, -1); // Remove last item
|
||||
navigate(`/library/folders?folderId=${previousFolder.id}`, {
|
||||
state: { breadcrumbPath: newPath },
|
||||
});
|
||||
folderActions.setPath(newPath);
|
||||
} else {
|
||||
// Go to root if no previous folder
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
}
|
||||
} else {
|
||||
// If no path, go to root
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
}
|
||||
}, [navigate, folderActions, path]);
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
(index: number) => {
|
||||
const currentPath = path || [];
|
||||
const targetPath = currentPath.slice(0, index + 1);
|
||||
|
||||
// Navigate to the selected folder
|
||||
if (index === -1) {
|
||||
// Root level
|
||||
navigate('/library/folders');
|
||||
folderActions.resetPath();
|
||||
} else {
|
||||
const targetFolder = targetPath[index];
|
||||
navigate(`/library/folders?folderId=${targetFolder.id}`, {
|
||||
state: { breadcrumbPath: targetPath },
|
||||
});
|
||||
folderActions.setPath(targetPath);
|
||||
}
|
||||
},
|
||||
[navigate, folderActions, path],
|
||||
);
|
||||
|
||||
const handlePlaySong = useCallback(
|
||||
(item: FolderItem) => {
|
||||
if (!item.isDir && item.id) {
|
||||
// Play the song
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [item.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
});
|
||||
}
|
||||
},
|
||||
[handlePlayQueueAdd],
|
||||
);
|
||||
|
||||
// Enhanced recursive function with caching, progress tracking, and cancellation
|
||||
const collectAllSongIds = useCallback(
|
||||
async (
|
||||
folderId: string,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<string[]> => {
|
||||
// Check for cancellation
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = ['folder-songs', server?.id, folderId];
|
||||
const cached = queryClient.getQueryData<string[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Use React Query's fetchQuery for better caching
|
||||
const folderData = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getFolderList({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal,
|
||||
},
|
||||
query: { id: folderId },
|
||||
}),
|
||||
queryKey: ['folder', server?.id, folderId],
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const songIds: string[] = [];
|
||||
const subfolders = folderData.items.filter((item) => item.isDir);
|
||||
const songs = folderData.items.filter((item) => !item.isDir);
|
||||
|
||||
// Add immediate song IDs
|
||||
songIds.push(...songs.map((song) => song.id));
|
||||
|
||||
// Process subfolders with controlled concurrency
|
||||
const BATCH_SIZE = 5; // Process 5 folders at a time
|
||||
let processedFolders = 0;
|
||||
|
||||
for (let i = 0; i < subfolders.length; i += BATCH_SIZE) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation cancelled');
|
||||
}
|
||||
|
||||
const batch = subfolders.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const batchPromises = batch.map(async (subfolder) => {
|
||||
try {
|
||||
return await collectAllSongIds(subfolder.id, signal, onProgress);
|
||||
} catch (error) {
|
||||
// Log error but continue with other folders
|
||||
console.warn(`Failed to collect songs from folder ${subfolder.id}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
songIds.push(...batchResults.flat());
|
||||
|
||||
processedFolders += batch.length;
|
||||
onProgress?.(processedFolders, subfolders.length);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
queryClient.setQueryData(cacheKey, songIds, {
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return songIds;
|
||||
},
|
||||
[server, queryClient],
|
||||
);
|
||||
|
||||
const handlePlayFolder = useCallback(
|
||||
async (e: React.MouseEvent, item: FolderItem | { id: string }) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Cancel any existing operation
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
setLoadingFolderId(item.id);
|
||||
|
||||
const notificationId = notifications.show({
|
||||
autoClose: false,
|
||||
id: 'folder-play-progress',
|
||||
loading: true,
|
||||
message: 'Preparing to play all songs',
|
||||
title: 'Scanning folder...',
|
||||
withCloseButton: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Collect song IDs with progress tracking
|
||||
const songIds = await collectAllSongIds(
|
||||
item.id,
|
||||
abortController.signal,
|
||||
(current, total) => {
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Processed ${current}/${total} folders`,
|
||||
title: 'Scanning folders...',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (songIds.length > 0) {
|
||||
// Remove from empty folders set if it has songs
|
||||
setEmptyFolders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(item.id);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Update notification for song fetching
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Loading ${songIds.length} songs`,
|
||||
title: 'Fetching song details...',
|
||||
});
|
||||
|
||||
// Fetch song details in batches to avoid overwhelming the server
|
||||
const BATCH_SIZE = 20;
|
||||
const allSongs: Song[] = [];
|
||||
|
||||
for (let i = 0; i < songIds.length; i += BATCH_SIZE) {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = songIds.slice(i, i + BATCH_SIZE);
|
||||
const songPromises = batch.map((songId) =>
|
||||
api.controller.getSongDetail({
|
||||
apiClientProps: {
|
||||
server: server as ServerListItemWithCredential,
|
||||
serverId: server?.id || '',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
query: { id: songId },
|
||||
}),
|
||||
);
|
||||
|
||||
const batchSongs = await Promise.all(songPromises);
|
||||
allSongs.push(...batchSongs);
|
||||
|
||||
// Update progress
|
||||
const loadedSongs = Math.min(i + BATCH_SIZE, songIds.length);
|
||||
notifications.update({
|
||||
id: notificationId,
|
||||
loading: true,
|
||||
message: `Loaded ${loadedSongs}/${songIds.length} songs`,
|
||||
title: 'Fetching song details...',
|
||||
});
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Play the songs
|
||||
handlePlayQueueAdd?.({
|
||||
byData: allSongs,
|
||||
playType: Play.NOW,
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
notifications.update({
|
||||
autoClose: 3000,
|
||||
color: 'green',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: `Started playing ${allSongs.length} songs`,
|
||||
title: 'Playing folder',
|
||||
});
|
||||
} else {
|
||||
// Mark folder as empty
|
||||
setEmptyFolders((prev) => new Set(prev).add(item.id));
|
||||
|
||||
notifications.update({
|
||||
autoClose: 3000,
|
||||
color: 'yellow',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: 'This folder contains no playable songs',
|
||||
title: 'No songs found',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
notifications.update({
|
||||
autoClose: 2000,
|
||||
color: 'orange',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: 'Folder playback was cancelled',
|
||||
title: 'Operation cancelled',
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
autoClose: 5000,
|
||||
color: 'red',
|
||||
id: notificationId,
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
title: 'Failed to play folder',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoadingFolderId(null);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[collectAllSongIds, handlePlayQueueAdd, server],
|
||||
);
|
||||
|
||||
const handleCancelPlayFolder = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setLoadingFolderId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className={styles.container} gap="md">
|
||||
{folderId && (
|
||||
<Group p="md">
|
||||
<Box
|
||||
aria-label="Go back to previous folder"
|
||||
className={styles['back-button']}
|
||||
component="button"
|
||||
onClick={handleBack}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBack();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<RiArrowLeftLine size={24} />
|
||||
</Box>
|
||||
<Breadcrumbs
|
||||
className={styles.breadcrumbs}
|
||||
separator={
|
||||
<Text c="dimmed" size="sm">
|
||||
/
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
className={styles['breadcrumb-item']}
|
||||
onClick={() => handleBreadcrumbClick(-1)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBreadcrumbClick(-1);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
aria-label="Go to folders list"
|
||||
c="blue"
|
||||
className={styles['breadcrumb-text']}
|
||||
size="sm"
|
||||
>
|
||||
Folders
|
||||
</Text>
|
||||
</Box>
|
||||
{path?.map((pathItem, index) =>
|
||||
index < path.length - 1 ? (
|
||||
<Box
|
||||
className={styles['breadcrumb-item']}
|
||||
key={pathItem.id}
|
||||
onClick={() => handleBreadcrumbClick(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBreadcrumbClick(index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
aria-label={`Navigate to: ${pathItem.name}`}
|
||||
c="blue"
|
||||
className={styles['breadcrumb-text']}
|
||||
size="sm"
|
||||
>
|
||||
{pathItem.name}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text
|
||||
aria-label={`Current folder: ${pathItem.name}`}
|
||||
c="white"
|
||||
className={styles['current-breadcrumb-text']}
|
||||
key={pathItem.id}
|
||||
role="text"
|
||||
size="sm"
|
||||
>
|
||||
{pathItem.name}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
</Group>
|
||||
)}
|
||||
{!folderId && (
|
||||
<Text fw={700} p="md" size="xl">
|
||||
Folders
|
||||
</Text>
|
||||
)}
|
||||
{folderQuery.isLoading && folderId && <Text p="md">Loading...</Text>}
|
||||
{musicFoldersQuery.isLoading && !folderId && <Text p="md">Loading...</Text>}
|
||||
{folderQuery.error && folderId && (
|
||||
<Stack gap="xs" p="md">
|
||||
<Text c="red">Error: {(folderQuery.error as Error)?.message}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Note: Browsing root folders requires using artist indexes. Try browsing from
|
||||
Albums or Artists views instead, or navigate directly to a subfolder if you
|
||||
know its ID.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{musicFoldersQuery.error && !folderId && (
|
||||
<Text c="red" p="md">
|
||||
Error: {(musicFoldersQuery.error as Error)?.message}
|
||||
</Text>
|
||||
)}
|
||||
<Box aria-label="Folder contents" className={styles['folder-content']} role="main">
|
||||
<Stack
|
||||
aria-label={`${folderId ? 'Folder contents' : 'Folders'} list`}
|
||||
gap="xs"
|
||||
role="list"
|
||||
>
|
||||
{checkingEmptyFolders ? (
|
||||
<Group justify="center" p="md">
|
||||
<Spinner container />
|
||||
<Text c="dimmed" size="sm">
|
||||
Checking folder contents...
|
||||
</Text>
|
||||
</Group>
|
||||
) : items.length === 0 &&
|
||||
!folderQuery.isLoading &&
|
||||
!musicFoldersQuery.isLoading ? (
|
||||
<Text c="dimmed" p="md">
|
||||
No items found
|
||||
</Text>
|
||||
) : null}
|
||||
{!checkingEmptyFolders &&
|
||||
items.map((item) => (
|
||||
<Group
|
||||
aria-label={`${item.isDir ? 'Folder' : 'Song'}: ${item.title || item.name}`}
|
||||
className={styles['folder-item']}
|
||||
key={item.id}
|
||||
onClick={() =>
|
||||
item.isDir ? handleFolderClick(item) : handlePlaySong(item)
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
if (item.isDir) {
|
||||
handleFolderContextMenu(e, [item]);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (item.isDir) {
|
||||
handleFolderClick(item);
|
||||
} else {
|
||||
handlePlaySong(item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
p="sm"
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
{item.isDir ? (
|
||||
<RiFolderLine size={24} />
|
||||
) : (
|
||||
<RiMusicLine size={24} />
|
||||
)}
|
||||
{item.isDir && !emptyFolders.has(item.id) && folderId && (
|
||||
<Tooltip
|
||||
label={loadingFolderId === item.id ? 'Stop' : 'Play all'}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={
|
||||
loadingFolderId === item.id
|
||||
? 'Stop playing folder'
|
||||
: `Play folder: ${item.title || item.name}`
|
||||
}
|
||||
color={loadingFolderId === item.id ? 'red' : 'blue'}
|
||||
onClick={(e) => {
|
||||
if (loadingFolderId === item.id) {
|
||||
handleCancelPlayFolder();
|
||||
} else {
|
||||
handlePlayFolder(e, item);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (loadingFolderId === item.id) {
|
||||
handleCancelPlayFolder();
|
||||
} else {
|
||||
handlePlayFolder(e as any, item);
|
||||
}
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
size="lg"
|
||||
tabIndex={0}
|
||||
variant="subtle"
|
||||
>
|
||||
{loadingFolderId === item.id ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<RiPlayFill size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text className={styles['song-title']}>
|
||||
{item.title || item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PageHeader } from '/@/renderer/components/page-header/page-header';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
|
||||
export const FolderListHeader = (): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const cq = useContainerQuery();
|
||||
|
||||
return (
|
||||
<Stack gap={0} ref={cq.ref}>
|
||||
<PageHeader>
|
||||
<Flex justify="space-between" w="100%">
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>
|
||||
{t('page.folderList.title', { postProcess: 'titleCase' })}
|
||||
</LibraryHeaderBar.Title>
|
||||
</LibraryHeaderBar>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
.container {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.breadcrumb-text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-breadcrumb-text {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.song-title {
|
||||
flex: 1;
|
||||
}
|
||||
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal file
32
src/renderer/features/folders/routes/folder-list-route.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
|
||||
import { ListContext } from '/@/renderer/context/list-context';
|
||||
import { FolderListContent } from '/@/renderer/features/folders/components/folder-list-content';
|
||||
import { FolderListHeader } from '/@/renderer/features/folders/components/folder-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||
|
||||
const FolderListRoute = () => {
|
||||
const gridRef = useRef<null | VirtualInfiniteGridRef>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const pageKey = 'folder';
|
||||
|
||||
const providerValue = useMemo(() => {
|
||||
return {
|
||||
pageKey,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<FolderListHeader />
|
||||
<FolderListContent gridRef={gridRef} itemCount={undefined} tableRef={tableRef} />
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderListRoute;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
|
||||
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
|
||||
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
|
||||
|
|
@ -24,6 +25,8 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
|
|||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import {
|
||||
Album,
|
||||
AlbumListResponse,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
|
|
@ -33,7 +36,7 @@ import {
|
|||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
const BASE_QUERY_ARGS = {
|
||||
limit: 15,
|
||||
limit: 30,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
};
|
||||
|
|
@ -173,20 +176,255 @@ const HomeRoute = () => {
|
|||
}),
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
(random.isLoading && queriesEnabled[HomeItem.RANDOM]) ||
|
||||
(recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) ||
|
||||
(recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
|
||||
(recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
|
||||
(((isJellyfin && mostPlayedSongs.isLoading) ||
|
||||
(!isJellyfin && mostPlayedAlbums.isLoading)) &&
|
||||
const starredAlbums = useQuery(
|
||||
albumQueries.list({
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.STARRED_ALBUMS],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
...BASE_QUERY_ARGS,
|
||||
favorite: true,
|
||||
sortBy: AlbumListSort.FAVORITED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
},
|
||||
serverId: server?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const starredTracks = useQuery(
|
||||
songsQueries.list(
|
||||
{
|
||||
options: {
|
||||
enabled: queriesEnabled[HomeItem.STARRED_TRACKS],
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
query: {
|
||||
...BASE_QUERY_ARGS,
|
||||
favorite: true,
|
||||
sortBy: SongListSort.FAVORITED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
},
|
||||
serverId: server?.id,
|
||||
},
|
||||
300,
|
||||
),
|
||||
);
|
||||
|
||||
// Flashback: Get a random decade from the past
|
||||
// Pre-compute which decades have albums to avoid empty queries
|
||||
const [flashbackSeed, setFlashbackSeed] = useState(0);
|
||||
const [hasFlashbackLoaded, setHasFlashbackLoaded] = useState(false);
|
||||
|
||||
// Get all available decades with albums
|
||||
const availableDecades = useMemo(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentDecade = Math.floor(currentYear / 10) * 10;
|
||||
const minDecade = 1920;
|
||||
const decades: Array<{ decade: number; maxYear: number; minYear: number }> = [];
|
||||
|
||||
for (let decade = minDecade; decade <= currentDecade; decade += 10) {
|
||||
decades.push({
|
||||
decade,
|
||||
maxYear: decade + 9,
|
||||
minYear: decade,
|
||||
});
|
||||
}
|
||||
|
||||
return decades;
|
||||
}, []);
|
||||
|
||||
// Get count for each decade
|
||||
const decadeQueries = useQuery({
|
||||
enabled: queriesEnabled[HomeItem.FLASHBACK] && !!server?.id,
|
||||
queryFn: async () => {
|
||||
if (!server?.id) return [];
|
||||
|
||||
const promises = availableDecades.map(async ({ decade, maxYear, minYear }) => {
|
||||
try {
|
||||
const count = await api.controller.getAlbumListCount({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: {
|
||||
maxYear,
|
||||
minYear,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
});
|
||||
return { count, decade, hasAlbums: count > 0 };
|
||||
} catch (error) {
|
||||
console.error(`Error checking decade ${decade}s:`, error);
|
||||
return { count: 0, decade, hasAlbums: false };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter((r) => r.hasAlbums);
|
||||
},
|
||||
queryKey: ['flashback-decades', server?.id],
|
||||
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
|
||||
});
|
||||
|
||||
// Get a random decade from available ones
|
||||
const { flashbackDecade, flashbackMaxYear, flashbackMinYear } = useMemo(() => {
|
||||
if (decadeQueries.data && decadeQueries.data.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * decadeQueries.data.length);
|
||||
const selectedDecade = decadeQueries.data[randomIndex];
|
||||
return {
|
||||
flashbackDecade: selectedDecade.decade,
|
||||
flashbackMaxYear: selectedDecade.decade + 9,
|
||||
flashbackMinYear: selectedDecade.decade,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to current decade if no data yet
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentDecade = Math.floor(currentYear / 10) * 10;
|
||||
return {
|
||||
flashbackDecade: currentDecade,
|
||||
flashbackMaxYear: currentDecade + 9,
|
||||
flashbackMinYear: currentDecade,
|
||||
};
|
||||
// flashbackSeed is intentionally included to force re-computation on refresh
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decadeQueries.data, flashbackSeed]);
|
||||
|
||||
const flashback = useQuery<AlbumListResponse>({
|
||||
enabled:
|
||||
queriesEnabled[HomeItem.FLASHBACK] &&
|
||||
decadeQueries.data &&
|
||||
decadeQueries.data.length > 0 &&
|
||||
!!server?.id,
|
||||
gcTime: 1000 * 60 * 5,
|
||||
placeholderData: (): AlbumListResponse => {
|
||||
// Create placeholder data with correct structure but empty items
|
||||
// This prevents UI jumping while avoiding cross-decade cache contamination
|
||||
return {
|
||||
items: Array(BASE_QUERY_ARGS.limit)
|
||||
.fill(null)
|
||||
.map((_, index) => ({
|
||||
albumArtist: '',
|
||||
albumArtists: [], // Required for card rows
|
||||
artists: [],
|
||||
backdropImageUrl: null,
|
||||
comment: null,
|
||||
createdAt: '',
|
||||
duration: null,
|
||||
explicitStatus: null,
|
||||
genres: [],
|
||||
id: `placeholder-${flashbackSeed}-${index}`,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: null,
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
mbzId: null,
|
||||
name: '',
|
||||
originalDate: null,
|
||||
participants: null,
|
||||
playCount: null,
|
||||
recordLabels: [],
|
||||
releaseDate: null,
|
||||
releaseTypes: [],
|
||||
releaseYear: null,
|
||||
serverId: server?.id || '',
|
||||
serverType: server?.type || ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: null,
|
||||
tags: null,
|
||||
uniqueId: `placeholder-${flashbackSeed}-${index}`,
|
||||
updatedAt: '',
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
version: null,
|
||||
})) as Album[],
|
||||
startIndex: 0,
|
||||
totalRecordCount: 0,
|
||||
};
|
||||
},
|
||||
queryFn: ({ signal }) => {
|
||||
const result = api.controller.getAlbumList({
|
||||
apiClientProps: { serverId: server?.id, signal },
|
||||
query: {
|
||||
...BASE_QUERY_ARGS,
|
||||
maxYear: flashbackMaxYear,
|
||||
minYear: flashbackMinYear,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
});
|
||||
result.then((data) => {
|
||||
console.log(
|
||||
`[Flashback] Fetched ${data.items?.length || 0} albums for ${flashbackDecade}s (seed: ${flashbackSeed})`,
|
||||
);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
queryKey: [
|
||||
'albums',
|
||||
'list',
|
||||
server?.id,
|
||||
{
|
||||
...BASE_QUERY_ARGS,
|
||||
_flashbackSeed: flashbackSeed, // Force unique cache key
|
||||
maxYear: flashbackMaxYear,
|
||||
minYear: flashbackMinYear,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
},
|
||||
],
|
||||
staleTime: 0, // Force fresh data every time to prevent cross-decade caching
|
||||
});
|
||||
|
||||
// Track when Flashback has successfully loaded at least once
|
||||
useEffect(() => {
|
||||
if (flashback.data && !hasFlashbackLoaded) {
|
||||
setHasFlashbackLoaded(true);
|
||||
}
|
||||
}, [flashback.data, hasFlashbackLoaded]);
|
||||
|
||||
// Only show flashback if we have decades with albums
|
||||
const shouldShowFlashback = decadeQueries.data && decadeQueries.data.length > 0;
|
||||
|
||||
// Only show full-page spinner on initial load, not on refetch
|
||||
const isInitialLoading =
|
||||
(random.isLoading && !random.data && queriesEnabled[HomeItem.RANDOM]) ||
|
||||
(recentlyPlayed.isLoading &&
|
||||
!recentlyPlayed.data &&
|
||||
queriesEnabled[HomeItem.RECENTLY_PLAYED] &&
|
||||
!isJellyfin) ||
|
||||
(recentlyAdded.isLoading &&
|
||||
!recentlyAdded.data &&
|
||||
queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
|
||||
(recentlyReleased.isLoading &&
|
||||
!recentlyReleased.data &&
|
||||
queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
|
||||
(starredAlbums.isLoading &&
|
||||
!starredAlbums.data &&
|
||||
queriesEnabled[HomeItem.STARRED_ALBUMS]) ||
|
||||
(starredTracks.isLoading &&
|
||||
!starredTracks.data &&
|
||||
queriesEnabled[HomeItem.STARRED_TRACKS]) ||
|
||||
(flashback.isLoading && !hasFlashbackLoaded && queriesEnabled[HomeItem.FLASHBACK]) ||
|
||||
(((isJellyfin && mostPlayedSongs.isLoading && !mostPlayedSongs.data) ||
|
||||
(!isJellyfin && mostPlayedAlbums.isLoading && !mostPlayedAlbums.data)) &&
|
||||
queriesEnabled[HomeItem.MOST_PLAYED]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isInitialLoading) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
const carousels = {
|
||||
const carousels: Record<HomeItem, any> = {
|
||||
[HomeItem.FLASHBACK]: {
|
||||
data: flashback?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
onRefresh: () => {
|
||||
// Incrementing seed changes query key, which automatically triggers a new fetch
|
||||
setFlashbackSeed((prev) => prev + 1);
|
||||
},
|
||||
query: flashback,
|
||||
title: `${t('page.home.flashback', { postProcess: 'sentenceCase' })} - ${flashbackDecade}s`,
|
||||
},
|
||||
[HomeItem.MOST_PLAYED]: {
|
||||
data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items,
|
||||
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
|
||||
|
|
@ -217,6 +455,18 @@ const HomeRoute = () => {
|
|||
query: recentlyReleased,
|
||||
title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.STARRED_ALBUMS]: {
|
||||
data: starredAlbums?.data?.items,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
query: starredAlbums,
|
||||
title: t('page.home.starredAlbums', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
[HomeItem.STARRED_TRACKS]: {
|
||||
data: starredTracks?.data?.items,
|
||||
itemType: LibraryItem.SONG,
|
||||
query: starredTracks,
|
||||
title: t('page.home.starredTracks', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
};
|
||||
|
||||
const sortedCarousel = homeItems
|
||||
|
|
@ -227,6 +477,10 @@ const HomeRoute = () => {
|
|||
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
|
||||
return false;
|
||||
}
|
||||
// Don't show flashback carousel if it has no data
|
||||
if (item.id === HomeItem.FLASHBACK && !shouldShowFlashback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
|
|
@ -310,7 +564,11 @@ const HomeRoute = () => {
|
|||
<Group>
|
||||
<TextTitle order={3}>{carousel.title}</TextTitle>
|
||||
<ActionIcon
|
||||
onClick={() => carousel.query.refetch()}
|
||||
onClick={() =>
|
||||
'onRefresh' in carousel
|
||||
? carousel.onRefresh()
|
||||
: carousel.query.refetch()
|
||||
}
|
||||
variant="transparent"
|
||||
>
|
||||
<Icon icon="refresh" />
|
||||
|
|
|
|||
|
|
@ -11,7 +11,15 @@ import { useMergedRef } from '@mantine/hooks';
|
|||
import '@ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import isElectron from 'is-electron';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||
|
|
@ -28,6 +36,7 @@ import {
|
|||
useCurrentStatus,
|
||||
useDefaultQueue,
|
||||
usePlayerControls,
|
||||
usePlayerStore,
|
||||
usePreviousSong,
|
||||
useQueueControls,
|
||||
useVolume,
|
||||
|
|
@ -53,9 +62,10 @@ type QueueProps = {
|
|||
|
||||
export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mergedRef = useMergedRef(ref, tableRef);
|
||||
const queue = useDefaultQueue();
|
||||
const { reorderQueue, setCurrentTrack } = useQueueControls();
|
||||
const { removeFromQueue, reorderQueue, setCurrentTrack } = useQueueControls();
|
||||
const currentSong = useCurrentSong();
|
||||
const previousSong = usePreviousSong();
|
||||
const status = useCurrentStatus();
|
||||
|
|
@ -257,42 +267,98 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
|
|||
|
||||
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Check if Delete or Backspace key was pressed
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const { api } = tableRef?.current || {};
|
||||
if (!api) return;
|
||||
|
||||
const selectedNodes = api.getSelectedNodes();
|
||||
if (!selectedNodes || selectedNodes.length === 0) return;
|
||||
|
||||
const uniqueIds = selectedNodes.map((node) => node.data?.uniqueId).filter(Boolean);
|
||||
if (!uniqueIds.length) return;
|
||||
|
||||
const currentSongState = usePlayerStore.getState().current.song;
|
||||
const playerData = removeFromQueue(uniqueIds as string[]);
|
||||
const isCurrentSongRemoved =
|
||||
currentSongState && uniqueIds.includes(currentSongState?.uniqueId);
|
||||
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
if (isCurrentSongRemoved) {
|
||||
setQueue(playerData, false);
|
||||
} else {
|
||||
setQueueNext(playerData);
|
||||
}
|
||||
}
|
||||
|
||||
api.redrawRows();
|
||||
|
||||
if (isCurrentSongRemoved) {
|
||||
updateSong(playerData.current.song);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[playbackType, removeFromQueue],
|
||||
);
|
||||
|
||||
// Add keyboard event listener to container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
alwaysShowHorizontalScroll
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
handleDoubleClick,
|
||||
isFocused,
|
||||
isQueue: true,
|
||||
itemType: LibraryItem.SONG,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
deselectOnClickOutside={type === 'fullScreen'}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
onCellDoubleClicked={handleDoubleClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onDragStarted={handleDragStart}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onRowDragEnd={handleDragEnd}
|
||||
ref={mergedRef}
|
||||
rowBuffer={50}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={songs}
|
||||
rowDragEntireRow
|
||||
rowDragMultiRow
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
suppressCellFocus={type === 'fullScreen'}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
alwaysShowHorizontalScroll
|
||||
autoFitColumns={tableConfig.autoFit}
|
||||
columnDefs={columnDefs}
|
||||
context={{
|
||||
currentSong,
|
||||
handleDoubleClick,
|
||||
isFocused,
|
||||
isQueue: true,
|
||||
itemType: LibraryItem.SONG,
|
||||
onCellContextMenu,
|
||||
status,
|
||||
}}
|
||||
deselectOnClickOutside={type === 'fullScreen'}
|
||||
getRowId={(data) => data.data.uniqueId}
|
||||
onCellContextMenu={onCellContextMenu}
|
||||
onCellDoubleClicked={handleDoubleClick}
|
||||
onColumnMoved={handleColumnChange}
|
||||
onColumnResized={debouncedColumnChange}
|
||||
onDragStarted={handleDragStart}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChange}
|
||||
onRowDragEnd={handleDragEnd}
|
||||
ref={mergedRef}
|
||||
rowBuffer={50}
|
||||
rowClassRules={rowClassRules}
|
||||
rowData={songs}
|
||||
rowDragEntireRow
|
||||
rowDragMultiRow
|
||||
rowHeight={tableConfig.rowHeight || 40}
|
||||
suppressCellFocus={type === 'fullScreen'}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,3 +38,31 @@
|
|||
background: var(--theme-overlay-header);
|
||||
backdrop-filter: blur(var(--image-blur));
|
||||
}
|
||||
|
||||
.scanline-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
transparent 0%,
|
||||
var(--album-color, rgb(0 255 255 / 15%)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100% 200%;
|
||||
animation: scanline 6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
background-position: 0 -100vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 100vh;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,6 +428,11 @@ export const FullScreenPlayer = () => {
|
|||
srcLoaded: true,
|
||||
});
|
||||
|
||||
// Convert RGB to RGB with opacity for scanline effect
|
||||
const scanlineColor = background
|
||||
? background.replace('rgb', 'rgba').replace(')', ', 0.15)')
|
||||
: 'rgba(0, 255, 255, 0.15)';
|
||||
|
||||
const imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
|
||||
const backgroundImage =
|
||||
imageUrl && dynamicIsImage
|
||||
|
|
@ -457,6 +462,14 @@ export const FullScreenPlayer = () => {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.scanlineOverlay}
|
||||
style={
|
||||
{
|
||||
'--album-color': scanlineColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<div className={styles.responsiveContainer}>
|
||||
<FullScreenPlayerImage />
|
||||
<FullScreenPlayerQueue />
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@
|
|||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: visible;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px var(--theme-colors-primary-filled) solid;
|
||||
outline: 2px var(--theme-orange-base) solid;
|
||||
box-shadow: var(--theme-shadow-orange-glow-soft);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 8px var(--theme-orange-transparent-40));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
|
@ -32,13 +38,24 @@
|
|||
|
||||
.player-button.active {
|
||||
svg {
|
||||
fill: var(--theme-colors-primary-filled);
|
||||
filter: drop-shadow(0 0 6px var(--theme-orange-transparent-50));
|
||||
fill: var(--theme-orange-base);
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
background: var(--theme-colors-foreground) !important;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--theme-orange-base),
|
||||
var(--theme-orange-medium)
|
||||
) !important;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--theme-shadow-orange-glow-soft);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--theme-shadow-orange-glow-medium);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
.container {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
border-top: var(--theme-colors-border);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--theme-colors-background) 0%,
|
||||
rgb(2 26 26 / 95%) 100%
|
||||
);
|
||||
border-top: 2px solid var(--theme-orange-transparent-40);
|
||||
box-shadow: 0 -4px 12px var(--theme-orange-transparent-15);
|
||||
}
|
||||
|
||||
.controls-grid {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { PlaybackType } from '/@/shared/types/types';
|
|||
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const remote = isElectron() ? window.api.remote : null;
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
|
||||
export const RightControls = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -218,6 +219,46 @@ export const RightControls = () => {
|
|||
)}
|
||||
</Group>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
{(playbackType === PlaybackType.LOCAL || playbackType === PlaybackType.WEB) && (
|
||||
<ActionIcon
|
||||
icon={
|
||||
playbackSettings.mpvProperties.audioChannels === 'mono'
|
||||
? 'volumeNormal'
|
||||
: 'volumeMax'
|
||||
}
|
||||
iconProps={{
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const current =
|
||||
playbackSettings.mpvProperties.audioChannels || 'stereo';
|
||||
const next = current === 'mono' ? 'stereo' : 'mono';
|
||||
setSettings({
|
||||
playback: {
|
||||
...playbackSettings,
|
||||
mpvProperties: {
|
||||
...playbackSettings.mpvProperties,
|
||||
audioChannels: next,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Apply to MPV immediately
|
||||
mpvPlayer?.setProperties({
|
||||
'audio-channels': next,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label:
|
||||
playbackType === PlaybackType.WEB
|
||||
? `Audio: ${playbackSettings.mpvProperties.audioChannels || 'stereo'} (MPV only)`
|
||||
: `Audio: ${playbackSettings.mpvProperties.audioChannels || 'stereo'}`,
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu arrowOffset={12} offset={0} position="top-end" width={425} withArrow>
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import { DraggableItems } from '/@/renderer/features/settings/components/general
|
|||
import { HomeItem, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
|
||||
const HOME_ITEMS: Array<[string, string]> = [
|
||||
[HomeItem.RANDOM, 'page.home.explore'],
|
||||
[HomeItem.FLASHBACK, 'page.home.flashback'],
|
||||
[HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'],
|
||||
[HomeItem.RANDOM, 'page.home.explore'],
|
||||
[HomeItem.RECENTLY_ADDED, 'page.home.newlyAdded'],
|
||||
[HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
|
||||
[HomeItem.STARRED_ALBUMS, 'page.home.starredAlbums'],
|
||||
[HomeItem.STARRED_TRACKS, 'page.home.starredTracks'],
|
||||
[HomeItem.MOST_PLAYED, 'page.home.mostPlayed'],
|
||||
[HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
|
||||
];
|
||||
|
||||
export const HomeSettings = () => {
|
||||
|
|
|
|||
|
|
@ -161,6 +161,48 @@ export const ThemeSettings = () => {
|
|||
}),
|
||||
title: t('setting.accentColor', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
checked={settings.enableShimmerEffect}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
enableShimmerEffect: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: t('setting.enableShimmerEffect', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: false,
|
||||
title: t('setting.enableShimmerEffect', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
checked={settings.enableScanlineEffect}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
general: {
|
||||
...settings,
|
||||
enableScanlineEffect: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: t('setting.enableScanlineEffect', {
|
||||
context: 'description',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
isHidden: false,
|
||||
title: t('setting.enableScanlineEffect', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
];
|
||||
|
||||
return <SettingsSection options={themeOptions} />;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export const getMpvSetting = (
|
|||
value: any,
|
||||
) => {
|
||||
switch (key) {
|
||||
case 'audioChannels':
|
||||
return { 'audio-channels': value };
|
||||
case 'audioExclusiveMode':
|
||||
return { 'audio-exclusive': value || 'no' };
|
||||
case 'audioSampleRateHz':
|
||||
|
|
@ -53,6 +55,7 @@ export const getMpvSetting = (
|
|||
|
||||
export const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => {
|
||||
const properties: Record<string, any> = {
|
||||
'audio-channels': settings.audioChannels || 'stereo',
|
||||
'audio-exclusive': settings.audioExclusiveMode || 'no',
|
||||
'audio-samplerate':
|
||||
settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz,
|
||||
|
|
@ -271,6 +274,22 @@ export const MpvSettings = () => {
|
|||
isHidden: settings.type !== PlaybackType.LOCAL,
|
||||
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Select
|
||||
data={[
|
||||
{ label: 'Mono', value: 'mono' },
|
||||
{ label: 'Stereo', value: 'stereo' },
|
||||
]}
|
||||
defaultValue={settings.mpvProperties.audioChannels || 'stereo'}
|
||||
onChange={(e) => handleSetMpvProperty('audioChannels', e)}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
'Select the audio channel mode. Stereo is the default, mono downmixes to a single channel.',
|
||||
isHidden: settings.type !== PlaybackType.LOCAL,
|
||||
title: 'Audio Channels',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const Sidebar = () => {
|
|||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function IsUpdatedDialog() {
|
|||
<Group justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://github.com/jeffvli/feishin/releases/tag/v${version}`}
|
||||
href={`https://github.com/antebudimir/feishin/releases/tag/v${version}`}
|
||||
onClick={handleDismiss}
|
||||
rightSection={<Icon icon="externalLink" />}
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
|
|||
|
||||
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
|
||||
|
||||
const FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route'));
|
||||
|
||||
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
|
||||
|
||||
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
|
||||
|
|
@ -161,6 +163,11 @@ export const AppRouter = () => {
|
|||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.LIBRARY_SONGS}
|
||||
/>
|
||||
<Route
|
||||
element={<FolderListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.LIBRARY_FOLDERS}
|
||||
/>
|
||||
<Route
|
||||
element={<PlaylistListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
|
|
|
|||
70
src/renderer/store/folder.store.ts
Normal file
70
src/renderer/store/folder.store.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { devtools } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export interface FolderStoreSlice extends FolderStoreState {
|
||||
actions: {
|
||||
popPath: () => void;
|
||||
pushPath: (path: { id: string; name: string }) => void;
|
||||
resetPath: () => void;
|
||||
setCurrentFolderId: (id: null | string) => void;
|
||||
setPath: (path: Array<{ id: string; name: string }>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FolderStoreState {
|
||||
currentFolderId: null | string;
|
||||
path: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export const useFolderStore = createWithEqualityFn<FolderStoreSlice>()(
|
||||
devtools(
|
||||
immer((set) => ({
|
||||
actions: {
|
||||
popPath: () => {
|
||||
set((state) => {
|
||||
if (state.path.length > 0) {
|
||||
state.path.pop();
|
||||
state.currentFolderId =
|
||||
state.path.length > 0 ? state.path[state.path.length - 1].id : null;
|
||||
}
|
||||
});
|
||||
},
|
||||
pushPath: (pathItem) => {
|
||||
set((state) => {
|
||||
state.path.push(pathItem);
|
||||
state.currentFolderId = pathItem.id;
|
||||
});
|
||||
},
|
||||
resetPath: () => {
|
||||
set((state) => {
|
||||
state.path = [];
|
||||
state.currentFolderId = null;
|
||||
});
|
||||
},
|
||||
setCurrentFolderId: (id) => {
|
||||
set((state) => {
|
||||
state.currentFolderId = id;
|
||||
});
|
||||
},
|
||||
setPath: (path) => {
|
||||
set((state) => {
|
||||
state.path = path;
|
||||
state.currentFolderId = path.length > 0 ? path[path.length - 1].id : null;
|
||||
});
|
||||
},
|
||||
},
|
||||
currentFolderId: null,
|
||||
path: [],
|
||||
})),
|
||||
{ name: 'store_folder' },
|
||||
),
|
||||
);
|
||||
|
||||
export const useFolderStoreActions = () => useFolderStore((state) => state.actions);
|
||||
|
||||
export const useFolderPath = () =>
|
||||
useFolderStore((state) => ({
|
||||
currentFolderId: state.currentFolderId,
|
||||
path: state.path,
|
||||
}));
|
||||
|
|
@ -28,11 +28,14 @@ import {
|
|||
} from '/@/shared/types/types';
|
||||
|
||||
const HomeItemSchema = z.enum([
|
||||
'flashback',
|
||||
'mostPlayed',
|
||||
'random',
|
||||
'recentlyAdded',
|
||||
'recentlyPlayed',
|
||||
'recentlyReleased',
|
||||
'starredAlbums',
|
||||
'starredTracks',
|
||||
]);
|
||||
|
||||
const ArtistItemSchema = z.enum([
|
||||
|
|
@ -121,6 +124,7 @@ const TranscodingConfigSchema = z.object({
|
|||
});
|
||||
|
||||
const MpvSettingsSchema = z.object({
|
||||
audioChannels: z.enum(['mono', 'stereo']).optional(),
|
||||
audioExclusiveMode: z.enum(['no', 'yes']),
|
||||
audioFormat: z.enum(['float', 's16', 's32']).optional(),
|
||||
audioSampleRateHz: z.number().optional(),
|
||||
|
|
@ -177,6 +181,8 @@ const GeneralSettingsSchema = z.object({
|
|||
buttonSize: z.number(),
|
||||
disabledContextMenu: z.record(z.boolean()),
|
||||
doubleClickQueueAll: z.boolean(),
|
||||
enableScanlineEffect: z.boolean(),
|
||||
enableShimmerEffect: z.boolean(),
|
||||
externalLinks: z.boolean(),
|
||||
followSystemTheme: z.boolean(),
|
||||
genreTarget: GenreTargetSchema,
|
||||
|
|
@ -388,11 +394,14 @@ export enum GenreTarget {
|
|||
}
|
||||
|
||||
export enum HomeItem {
|
||||
FLASHBACK = 'flashback',
|
||||
MOST_PLAYED = 'mostPlayed',
|
||||
RANDOM = 'random',
|
||||
RECENTLY_ADDED = 'recentlyAdded',
|
||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
||||
RECENTLY_RELEASED = 'recentlyReleased',
|
||||
STARRED_ALBUMS = 'starredAlbums',
|
||||
STARRED_TRACKS = 'starredTracks',
|
||||
}
|
||||
|
||||
export type DataTableProps = z.infer<typeof DataTablePropsSchema>;
|
||||
|
|
@ -475,6 +484,12 @@ export const sidebarItems: SidebarItemType[] = [
|
|||
label: i18n.t('page.sidebar.genres'),
|
||||
route: AppRoute.LIBRARY_GENRES,
|
||||
},
|
||||
{
|
||||
disabled: false,
|
||||
id: 'Folders',
|
||||
label: i18n.t('page.sidebar.folders'),
|
||||
route: AppRoute.LIBRARY_FOLDERS,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
id: 'Playlists',
|
||||
|
|
@ -489,10 +504,16 @@ export const sidebarItems: SidebarItemType[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const homeItems = Object.values(HomeItem).map((item) => ({
|
||||
disabled: false,
|
||||
id: item,
|
||||
}));
|
||||
const homeItems: SortableItem<HomeItem>[] = [
|
||||
{ disabled: false, id: HomeItem.FLASHBACK },
|
||||
{ disabled: false, id: HomeItem.RECENTLY_PLAYED },
|
||||
{ disabled: false, id: HomeItem.RANDOM },
|
||||
{ disabled: false, id: HomeItem.RECENTLY_ADDED },
|
||||
{ disabled: false, id: HomeItem.STARRED_ALBUMS },
|
||||
{ disabled: false, id: HomeItem.STARRED_TRACKS },
|
||||
{ disabled: false, id: HomeItem.MOST_PLAYED },
|
||||
{ disabled: true, id: HomeItem.RECENTLY_RELEASED },
|
||||
];
|
||||
|
||||
const artistItems = Object.values(ArtistItem).map((item) => ({
|
||||
disabled: false,
|
||||
|
|
@ -524,30 +545,32 @@ const initialState: SettingsState = {
|
|||
font: {
|
||||
builtIn: 'Poppins',
|
||||
custom: null,
|
||||
system: null,
|
||||
type: FontType.BUILT_IN,
|
||||
system: 'Noto Sans Regular',
|
||||
type: FontType.SYSTEM,
|
||||
},
|
||||
general: {
|
||||
accent: 'rgb(53, 116, 252)',
|
||||
accent: 'rgb(255, 142, 83)',
|
||||
albumArtRes: undefined,
|
||||
albumBackground: false,
|
||||
albumBackgroundBlur: 6,
|
||||
albumBackground: true,
|
||||
albumBackgroundBlur: 50,
|
||||
artistBackground: false,
|
||||
artistBackgroundBlur: 6,
|
||||
artistItems,
|
||||
buttonSize: 15,
|
||||
buttonSize: 25,
|
||||
disabledContextMenu: {},
|
||||
doubleClickQueueAll: true,
|
||||
doubleClickQueueAll: false,
|
||||
enableScanlineEffect: true,
|
||||
enableShimmerEffect: true,
|
||||
externalLinks: true,
|
||||
followSystemTheme: false,
|
||||
genreTarget: GenreTarget.TRACK,
|
||||
genreTarget: GenreTarget.ALBUM,
|
||||
homeFeature: true,
|
||||
homeItems,
|
||||
language: 'en',
|
||||
lastFM: true,
|
||||
lastfmApiKey: '',
|
||||
musicBrainz: true,
|
||||
nativeAspectRatio: false,
|
||||
nativeAspectRatio: true,
|
||||
passwordStore: undefined,
|
||||
playButtonBehavior: Play.NOW,
|
||||
playerbarOpenDrawer: false,
|
||||
|
|
@ -557,18 +580,18 @@ const initialState: SettingsState = {
|
|||
sidebarCollapseShared: false,
|
||||
sidebarItems,
|
||||
sidebarPlaylistList: true,
|
||||
sideQueueType: 'sideQueue',
|
||||
sideQueueType: 'sideDrawerQueue',
|
||||
skipButtons: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
skipBackwardSeconds: 5,
|
||||
skipForwardSeconds: 10,
|
||||
skipForwardSeconds: 5,
|
||||
},
|
||||
theme: AppTheme.DEFAULT_DARK,
|
||||
themeDark: AppTheme.DEFAULT_DARK,
|
||||
themeLight: AppTheme.DEFAULT_LIGHT,
|
||||
volumeWheelStep: 5,
|
||||
volumeWidth: 70,
|
||||
zoomFactor: 100,
|
||||
volumeWidth: 200,
|
||||
zoomFactor: 145,
|
||||
},
|
||||
hotkeys: {
|
||||
bindings: {
|
||||
|
|
@ -614,7 +637,7 @@ const initialState: SettingsState = {
|
|||
delayMs: 0,
|
||||
enableAutoTranslation: false,
|
||||
enableNeteaseTranslation: false,
|
||||
fetch: false,
|
||||
fetch: true,
|
||||
follow: true,
|
||||
fontSize: 24,
|
||||
fontSizeUnsync: 24,
|
||||
|
|
@ -630,14 +653,15 @@ const initialState: SettingsState = {
|
|||
},
|
||||
playback: {
|
||||
audioDeviceId: undefined,
|
||||
crossfadeDuration: 5,
|
||||
crossfadeStyle: CrossfadeStyle.EQUALPOWER,
|
||||
crossfadeDuration: 75,
|
||||
crossfadeStyle: CrossfadeStyle.DIPPED,
|
||||
mediaSession: false,
|
||||
mpvExtraParameters: [],
|
||||
mpvProperties: {
|
||||
audioChannels: 'stereo',
|
||||
audioExclusiveMode: 'no',
|
||||
audioFormat: undefined,
|
||||
audioSampleRateHz: 0,
|
||||
audioSampleRateHz: 48000,
|
||||
gaplessAudio: 'weak',
|
||||
replayGainClip: true,
|
||||
replayGainFallbackDB: undefined,
|
||||
|
|
@ -656,11 +680,11 @@ const initialState: SettingsState = {
|
|||
transcode: {
|
||||
enabled: false,
|
||||
},
|
||||
type: PlaybackType.WEB,
|
||||
type: PlaybackType.LOCAL,
|
||||
webAudio: true,
|
||||
},
|
||||
remote: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
password: randomString(8),
|
||||
port: 4333,
|
||||
username: 'feishin',
|
||||
|
|
|
|||
|
|
@ -6,19 +6,6 @@ import merge from 'lodash/merge';
|
|||
|
||||
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
|
||||
|
||||
// const lightColors: MantineColorsTuple = [
|
||||
// '#f5f5f5',
|
||||
// '#e7e7e7',
|
||||
// '#cdcdcd',
|
||||
// '#b2b2b2',
|
||||
// '#9a9a9a',
|
||||
// '#8b8b8b',
|
||||
// '#848484',
|
||||
// '#717171',
|
||||
// '#656565',
|
||||
// '#575757',
|
||||
// ];
|
||||
|
||||
const darkColors: MantineColorsTuple = [
|
||||
'#C9C9C9',
|
||||
'#b8b8b8',
|
||||
|
|
|
|||
|
|
@ -334,9 +334,51 @@ const normalizeGenre = (item: z.infer<typeof ssType._response.genre>): Genre =>
|
|||
};
|
||||
};
|
||||
|
||||
const normalizeFolderItem = (
|
||||
item: z.infer<typeof ssType._response.directoryChild>,
|
||||
server?: null | ServerListItemWithCredential,
|
||||
): import('/@/shared/types/domain-types').FolderItem => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt?.toString(),
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumId: item.albumId?.toString(),
|
||||
artist: item.artist,
|
||||
artistId: item.artistId?.toString(),
|
||||
coverArt: item.coverArt?.toString(),
|
||||
created: item.created,
|
||||
duration: item.duration,
|
||||
genre: item.genre,
|
||||
id: item.id.toString(),
|
||||
imageUrl,
|
||||
isDir: item.isDir,
|
||||
itemType: LibraryItem.FOLDER,
|
||||
name: item.title,
|
||||
parent: item.parent,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
starred: !!item.starred,
|
||||
suffix: item.suffix,
|
||||
title: item.title,
|
||||
track: item.track,
|
||||
userRating: item.userRating,
|
||||
year: item.year,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
folderItem: normalizeFolderItem,
|
||||
genre: normalizeGenre,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
|
|
|
|||
|
|
@ -548,6 +548,73 @@ const albumInfo = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
const directoryChild = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: id.optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: id.optional(),
|
||||
averageRating: z.number().optional(),
|
||||
contentType: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id,
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean().optional(),
|
||||
parent: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
playCount: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
starred: z.string().optional(),
|
||||
suffix: z.string().optional(),
|
||||
title: z.string(),
|
||||
track: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const directory = z.object({
|
||||
averageRating: z.number().optional(),
|
||||
child: z.array(directoryChild).optional(),
|
||||
id,
|
||||
name: z.string(),
|
||||
parent: z.string().optional(),
|
||||
playCount: z.number().optional(),
|
||||
starred: z.string().optional(),
|
||||
userRating: z.number().optional(),
|
||||
});
|
||||
|
||||
const getMusicDirectoryParameters = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const getMusicDirectory = z.object({
|
||||
directory,
|
||||
});
|
||||
|
||||
const getIndexesParameters = z.object({
|
||||
ifModifiedSince: z.number().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
});
|
||||
|
||||
const getIndexes = z.object({
|
||||
indexes: z.object({
|
||||
ignoredArticles: z.string().optional(),
|
||||
index: z
|
||||
.array(
|
||||
z.object({
|
||||
artist: z.array(artistListEntry).optional(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
lastModified: z.number(),
|
||||
shortcut: z.array(artistListEntry).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
|
|
@ -563,6 +630,8 @@ export const ssType = {
|
|||
getArtists: getArtistsParameters,
|
||||
getGenre: getGenresParameters,
|
||||
getGenres: getGenresParameters,
|
||||
getIndexes: getIndexesParameters,
|
||||
getMusicDirectory: getMusicDirectoryParameters,
|
||||
getPlaylist: getPlaylistParameters,
|
||||
getPlaylists: getPlaylistsParameters,
|
||||
getSong: getSongParameters,
|
||||
|
|
@ -591,12 +660,16 @@ export const ssType = {
|
|||
baseResponse,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
directory,
|
||||
directoryChild,
|
||||
genre,
|
||||
getAlbum,
|
||||
getAlbumList2,
|
||||
getArtist,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getIndexes,
|
||||
getMusicDirectory,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getSong,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,50 @@ html {
|
|||
background: var(--theme-colors-background);
|
||||
}
|
||||
|
||||
body.enable-shimmer::before,
|
||||
html.enable-shimmer::before {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 40%,
|
||||
rgb(0 183 255 / 10%) 45%,
|
||||
rgb(0 183 255 / 20%) 50%,
|
||||
rgb(0 183 255 / 10%) 55%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 8s infinite;
|
||||
}
|
||||
|
||||
body.enable-scanline::after,
|
||||
html.enable-scanline::after {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
transparent 0%,
|
||||
rgb(0 255 255 / 3%) 25%,
|
||||
transparent 50%,
|
||||
rgb(0 255 255 / 3%) 75%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100% 200%;
|
||||
animation: scanline 6s linear infinite;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
|
|
@ -58,6 +102,8 @@ img {
|
|||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +166,38 @@ button {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% {
|
||||
background-position: 0 -100vh;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Archivo;
|
||||
font-weight: 100 1000;
|
||||
|
|
@ -224,6 +302,48 @@ button {
|
|||
:root {
|
||||
--theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');
|
||||
--theme-fullscreen-player-text-shadow: black 0px 0px 10px;
|
||||
--theme-orange-base: rgb(255 142 83);
|
||||
--theme-orange-medium: rgb(255 123 52);
|
||||
--theme-orange-dark: rgb(255 89 0);
|
||||
--theme-orange-transparent-70: rgb(255 142 83 / 70%);
|
||||
--theme-orange-transparent-40: rgb(255 142 83 / 40%);
|
||||
--theme-orange-transparent-30: rgb(255 142 83 / 30%);
|
||||
--theme-orange-transparent-20: rgb(255 142 83 / 20%);
|
||||
--theme-orange-transparent-15: rgb(255 142 83 / 15%);
|
||||
--theme-orange-transparent-10: rgb(255 142 83 / 10%);
|
||||
--theme-cyan-primary: rgb(0 183 255);
|
||||
--theme-cyan-secondary: rgb(0 255 255);
|
||||
--theme-cyan-transparent-80: rgb(0 183 255 / 80%);
|
||||
--theme-cyan-transparent-70: rgb(0 183 255 / 70%);
|
||||
--theme-cyan-transparent-60: rgb(0 183 255 / 60%);
|
||||
--theme-cyan-transparent-50: rgb(0 183 255 / 50%);
|
||||
--theme-cyan-transparent-40: rgb(0 183 255 / 40%);
|
||||
--theme-cyan-transparent-30: rgb(0 183 255 / 30%);
|
||||
--theme-cyan-transparent-20: rgb(0 183 255 / 20%);
|
||||
--theme-cyan-transparent-10: rgb(0 183 255 / 10%);
|
||||
--theme-cyan-transparent-05: rgb(0 183 255 / 5%);
|
||||
--theme-cyan-transparent-03: rgb(0 183 255 / 3%);
|
||||
|
||||
/* Gradients */
|
||||
--theme-primary-gradient: linear-gradient(
|
||||
45deg,
|
||||
var(--theme-orange-dark),
|
||||
var(--theme-orange-medium),
|
||||
var(--theme-orange-base)
|
||||
);
|
||||
--theme-cyan-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-cyan-primary),
|
||||
var(--theme-cyan-secondary)
|
||||
);
|
||||
|
||||
/* Shadows */
|
||||
--theme-shadow-cyan-glow: 0 0 10px var(--theme-cyan-transparent-30);
|
||||
--theme-shadow-cyan-glow-medium: 0 0 15px var(--theme-cyan-transparent-20);
|
||||
--theme-shadow-orange-glow-soft: 0 4px 12px var(--theme-orange-transparent-15);
|
||||
--theme-shadow-orange-glow-medium:
|
||||
0 6px 20px var(--theme-orange-transparent-30), 0 0 20px var(--theme-orange-transparent-20);
|
||||
--theme-text-shadow-cyan: 0 0 20px var(--theme-cyan-transparent-30);
|
||||
--theme-font-size-xs: var(--mantine-font-size-xs);
|
||||
--theme-font-size-sm: var(--mantine-font-size-sm);
|
||||
--theme-font-size-md: var(--mantine-font-size-md);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ export const defaultTheme: AppThemeConfiguration = {
|
|||
'overlay-subheader':
|
||||
'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
|
||||
'root-font-size': '16px',
|
||||
'scrollbar-handle-active-background': 'rgba(160, 160, 160, 60%)',
|
||||
'scrollbar-handle-background': 'rgba(160, 160, 160, 30%)',
|
||||
'scrollbar-handle-border-radius': '0px',
|
||||
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 60%)',
|
||||
'scrollbar-handle-active-background': 'rgba(255, 142, 83, 0.7)',
|
||||
'scrollbar-handle-background': 'rgba(255, 142, 83, 0.4)',
|
||||
'scrollbar-handle-border-radius': '0.3rem',
|
||||
'scrollbar-handle-hover-background': 'rgba(255, 142, 83, 0.9)',
|
||||
'scrollbar-size': '12px',
|
||||
'scrollbar-track-active-background': 'transparent',
|
||||
'scrollbar-track-background': 'transparent',
|
||||
|
|
@ -18,17 +18,18 @@ export const defaultTheme: AppThemeConfiguration = {
|
|||
'scrollbar-track-hover-background': 'transparent',
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(16, 16, 16)',
|
||||
'background-alternate': 'rgb(0, 0, 0)',
|
||||
background: 'rgb(2, 26, 26)',
|
||||
'background-alternate': 'rgb(19, 16, 16)',
|
||||
black: 'rgb(0, 0, 0)',
|
||||
foreground: 'rgb(225, 225, 225)',
|
||||
'foreground-muted': 'rgb(150, 150, 150)',
|
||||
'state-error': 'rgb(204, 50, 50)',
|
||||
'state-info': 'rgb(53, 116, 252)',
|
||||
'state-success': 'rgb(50, 204, 50)',
|
||||
'state-warning': 'rgb(255, 120, 120)',
|
||||
surface: 'rgb(24, 24, 24)',
|
||||
'surface-foreground': 'rgb(215, 215, 215)',
|
||||
foreground: 'rgb(240, 240, 240)',
|
||||
'foreground-muted': 'rgb(187, 187, 187)',
|
||||
primary: 'rgb(255, 142, 83)',
|
||||
'state-error': 'rgb(255, 0, 0)',
|
||||
'state-info': 'rgb(0, 183, 255)',
|
||||
'state-success': 'rgb(0, 255, 255)',
|
||||
'state-warning': 'rgb(255, 142, 83)',
|
||||
surface: 'rgba(2, 26, 26, 0.8)',
|
||||
'surface-foreground': 'rgb(240, 240, 240)',
|
||||
white: 'rgb(255, 255, 255)',
|
||||
},
|
||||
mode: 'dark',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export enum LibraryItem {
|
|||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'albumArtist',
|
||||
ARTIST = 'artist',
|
||||
FOLDER = 'folder',
|
||||
GENRE = 'genre',
|
||||
PLAYLIST = 'playlist',
|
||||
SONG = 'song',
|
||||
|
|
@ -255,6 +256,47 @@ export type EndpointDetails = {
|
|||
server: ServerListItem;
|
||||
};
|
||||
|
||||
export type FolderItem = {
|
||||
album?: string;
|
||||
albumId?: string;
|
||||
artist?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
created?: string;
|
||||
duration?: number;
|
||||
genre?: string;
|
||||
id: string;
|
||||
imageUrl: null | string;
|
||||
isDir: boolean;
|
||||
itemType: LibraryItem.FOLDER;
|
||||
name: string;
|
||||
parent?: string;
|
||||
path?: string;
|
||||
playCount?: number;
|
||||
serverId: string;
|
||||
serverType: ServerType;
|
||||
size?: number;
|
||||
starred?: boolean;
|
||||
suffix?: string;
|
||||
title: string;
|
||||
track?: number;
|
||||
userRating?: number;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
export type FolderListArgs = BaseEndpointArgs & { query: FolderListQuery };
|
||||
|
||||
export interface FolderListQuery {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type FolderListResponse = {
|
||||
id: string;
|
||||
items: FolderItem[];
|
||||
name: string;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
export type GainInfo = {
|
||||
album?: number;
|
||||
track?: number;
|
||||
|
|
@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
|
|||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
|
||||
getDownloadUrl: (args: DownloadArgs) => string;
|
||||
getFolderList: (args: FolderListArgs) => Promise<FolderListResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
|
|
@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
|
|||
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
|
||||
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
|
||||
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
|
||||
getFolderList: (args: ReplaceApiClientProps<FolderListArgs>) => Promise<FolderListResponse>;
|
||||
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
|
||||
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue