Compare commits

...

6 commits

Author SHA1 Message Date
21e5a4dfd2
Merge branch 'jeffvli:development' into development
Some checks failed
Test / lint (push) Has been cancelled
2025-11-27 18:37:18 +00:00
4193dd36a1 Update README.md 2025-11-27 20:36:51 +02:00
59e94318bb feat: add audio channel configuration for MPV player
- Add audio channels setting (auto/mono/stereo) to MPV properties
- Implement UI controls in settings panel and player right controls
- Enable immediate MPV property application when setting changes
- Update default playback type to LOCAL and sample rate to 48kHz
2025-11-27 20:16:06 +02:00
bf5e7bc774 feat: add visual effects and enhance home screen functionality
- Add configurable shimmer and scanline visual effects with toggle settings
- Introduce starred albums and tracks sections to home screen
- Add flashback feature for album recommendations by decades
- Enhance home screen with increased item limits (30)
- Update default color scheme to orange-based theme
- Implement Backspace/Delete key functionality for removing songs from queue in fullscreen mode
2025-11-16 17:09:33 +02:00
228fc8e82b feat: add cyan-orange color scheme with enhanced visual effects
- Add new CSS animations (hologramGlow, float, shimmer, scanline)
- Introduce cyan and orange color palette variants
- Update scrollbar styling with orange theme colors
- Modify player components with new gradient backgrounds
- Update global theme colors and surface styling
- Adjust default settings for enhanced visual experience
2025-11-16 17:09:33 +02:00
7a12e4657f feat: implement folder list view with navigation and playback
- Add folder browsing functionality with breadcrumb navigation
- Implement folder playback with recursive song scanning and progress notifications
- Add browser history support for back/forward button navigation
- Include empty folder detection and loading states
2025-11-16 17:09:33 +02:00
43 changed files with 2045 additions and 207 deletions

View file

@ -2,17 +2,21 @@
# Feishin # 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"> <p align="center">
<a href="https://github.com/jeffvli/feishin/blob/main/LICENSE"> <a href="https://github.com/antebudimir/feishin/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen" <img src="https://img.shields.io/github/license/antebudimir/feishin?style=flat-square&color=brightgreen"
alt="License"> alt="License">
</a> </a>
<a href="https://github.com/jeffvli/feishin/releases"> <a href="https://github.com/antebudimir/feishin/releases">
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue" <img src="https://img.shields.io/github/v/release/antebudimir/feishin?style=flat-square&color=blue"
alt="Release"> alt="Release">
</a> </a>
<a href="https://github.com/jeffvli/feishin/releases"> <a href="https://github.com/antebudimir/feishin/releases">
<img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange" <img src="https://img.shields.io/github/downloads/antebudimir/feishin/total?style=flat-square&color=orange"
alt="Downloads"> alt="Downloads">
</a> </a>
</p> </p>
@ -39,85 +43,16 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
- [x] Scrobble playback to your server - [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome) - [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support - [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 ## 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 ## 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. 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.
#### 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
```
### Configuration ### Configuration
@ -165,7 +100,7 @@ chmod 4755 chrome-sandbox
sudo chown root:root 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 ## 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 lint:fix` - Lint the project and fix linting errors
- `pnpm run i18next` - Generate i18n files - `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 ## License
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE) [GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)

View file

@ -52,7 +52,7 @@ linux:
npmRebuild: false npmRebuild: false
publish: publish:
provider: github provider: github
owner: jeffvli owner: antebudimir
repo: feishin repo: feishin
channel: latest channel: latest
releaseType: draft releaseType: draft

View file

@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "0.21.2", "version": "0.25.0-fork",
"description": "A modern self-hosted music player.", "description": "A modern self-hosted music player.",
"keywords": [ "keywords": [
"subsonic", "subsonic",

View file

@ -403,6 +403,10 @@
"visualizer": "visualizer", "visualizer": "visualizer",
"noLyrics": "no lyrics found" "noLyrics": "no lyrics found"
}, },
"folderList": {
"title": "Folders",
"description": "Browse music by folder structure"
},
"genreList": { "genreList": {
"showAlbums": "show $t(entity.genre_one) $t(entity.album_other)", "showAlbums": "show $t(entity.genre_one) $t(entity.album_other)",
"showTracks": "show $t(entity.genre_one) $t(entity.track_other)", "showTracks": "show $t(entity.genre_one) $t(entity.track_other)",
@ -417,11 +421,14 @@
"title": "commands" "title": "commands"
}, },
"home": { "home": {
"explore": "explore from your library", "explore": "discovery",
"flashback": "flashback",
"mostPlayed": "most played", "mostPlayed": "most played",
"newlyAdded": "newly added releases", "newlyAdded": "recently added",
"recentlyPlayed": "recently played", "recentlyPlayed": "recently played",
"recentlyReleased": "recently released", "recentlyReleased": "recently released",
"starredAlbums": "starred albums",
"starredTracks": "starred tracks",
"title": "$t(common.home)" "title": "$t(common.home)"
}, },
"itemDetail": { "itemDetail": {
@ -596,6 +603,10 @@
"enableAutoTranslation": "enable auto translation", "enableAutoTranslation": "enable auto translation",
"enableRemote_description": "enables the remote control server to allow other devices to control the application", "enableRemote_description": "enables the remote control server to allow other devices to control the application",
"enableRemote": "enable remote control server", "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_description": "exit the application to the system tray",
"exitToTray": "exit to tray", "exitToTray": "exit to tray",
"exportImportSettings_control_description": "export and import settings via JSON", "exportImportSettings_control_description": "export and import settings via JSON",

View file

@ -281,6 +281,20 @@ export const controller: GeneralController = {
server.type, server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); )?.({ ...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) { getGenreList(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);

View file

@ -386,6 +386,11 @@ export const JellyfinController: InternalControllerEndpoint = {
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`; 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) => { getGenreList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@ -1085,6 +1090,7 @@ export const JellyfinController: InternalControllerEndpoint = {
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')), songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
}; };
}, },
updatePlaylist: async (args) => { updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args; const { apiClientProps, body, query } = args;

View file

@ -353,6 +353,11 @@ export const NavidromeController: InternalControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 }, query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!), }).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl, 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) => { getGenreList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@ -716,6 +721,7 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id, id: res.body.data.id,
}; };
}, },
updatePlaylist: async (args) => { updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args; const { apiClientProps, body, query } = args;

View file

@ -4,6 +4,7 @@ import type {
AlbumDetailQuery, AlbumDetailQuery,
AlbumListQuery, AlbumListQuery,
ArtistListQuery, ArtistListQuery,
FolderListQuery,
GenreListQuery, GenreListQuery,
LyricSearchQuery, LyricSearchQuery,
LyricsQuery, LyricsQuery,
@ -158,6 +159,13 @@ export const queryKeys: Record<
}, },
root: (serverId: string) => [serverId, 'artists'] as const, 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: { genres: {
list: (serverId: string, query?: GenreListQuery) => { list: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query); const { filter, pagination } = splitPaginatedQuery(query);

View file

@ -100,6 +100,22 @@ export const contract = c.router({
200: ssType._response.getGenres, 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: { getMusicFolderList: {
method: 'GET', method: 'GET',
path: 'getMusicFolders.view', path: 'getMusicFolders.view',

View file

@ -620,6 +620,77 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin' '&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 }) => { getGenreList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';

View file

@ -51,6 +51,8 @@ const utils = isElectron() ? window.api.utils : null;
export const App = () => { export const App = () => {
const { mode, theme } = useAppTheme(); const { mode, theme } = useAppTheme();
const language = useSettingsStore((store) => store.general.language); 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 { content, enabled } = useCssSettings();
const { type: playbackType } = usePlaybackSettings(); const { type: playbackType } = usePlaybackSettings();
@ -188,6 +190,24 @@ export const App = () => {
} }
}, [language]); }, [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 ( return (
<MantineProvider forceColorScheme={mode} theme={theme}> <MantineProvider forceColorScheme={mode} theme={theme}>
<Notifications <Notifications

View file

@ -22,7 +22,10 @@
aspect-ratio: 1/1; aspect-ratio: 1/1;
overflow: hidden; overflow: hidden;
background: var(--theme-card-default-bg); 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 { &::before {
position: absolute; position: absolute;
@ -39,6 +42,13 @@
} }
&:hover { &: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 { &::before {
opacity: 0.5; opacity: 0.5;
} }

View file

@ -23,6 +23,10 @@
display: flex; display: flex;
grid-area: image; grid-area: image;
align-items: flex-end; align-items: flex-end;
img {
border-radius: var(--theme-radius-md);
}
} }
.info-column { .info-column {

View file

@ -3,6 +3,8 @@
z-index: 190; z-index: 190;
width: 100%; width: 100%;
height: 65px; height: 65px;
border-bottom: 2px solid var(--theme-orange-transparent-30);
box-shadow: 0 4px 12px var(--theme-orange-transparent-10);
} }
.header { .header {

View file

@ -25,6 +25,11 @@
align-items: center; align-items: center;
aspect-ratio: 1/1; aspect-ratio: 1/1;
overflow: hidden; 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 { &::before {
position: absolute; position: absolute;
@ -41,6 +46,12 @@
} }
&:hover { &: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 { &::before {
opacity: 0.5; opacity: 0.5;
} }
@ -72,7 +83,6 @@
height: 100% !important; height: 100% !important;
max-height: 100%; max-height: 100%;
border: 0; border: 0;
border-radius: var(--theme-radius-md);
img { img {
height: 100%; height: 100%;

View file

@ -91,6 +91,14 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'showDetails' }, { 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 = [ export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ id: 'play' }, { id: 'play' },
{ id: 'playLast' }, { id: 'playLast' },

View file

@ -6,6 +6,7 @@ import {
Album, Album,
AlbumArtist, AlbumArtist,
Artist, Artist,
FolderItem,
LibraryItem, LibraryItem,
QueueSong, QueueSong,
Song, Song,
@ -72,7 +73,7 @@ export const useHandleGeneralContextMenu = (
) => { ) => {
const handleContextMenu = ( const handleContextMenu = (
e: any, e: any,
data: Album[] | AlbumArtist[] | Artist[] | QueueSong[] | Song[], data: Album[] | AlbumArtist[] | Artist[] | FolderItem[] | QueueSong[] | Song[],
) => { ) => {
if (!e) return; if (!e) return;
const clickEvent = e as MouseEvent; const clickEvent = e as MouseEvent;

View 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,
});
},
};

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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;
}

View 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;

View file

@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel'; import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel'; import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; 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 { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title'; import { TextTitle } from '/@/shared/components/text-title/text-title';
import { import {
Album,
AlbumListResponse,
AlbumListSort, AlbumListSort,
LibraryItem, LibraryItem,
ServerType, ServerType,
@ -33,7 +36,7 @@ import {
import { Platform } from '/@/shared/types/types'; import { Platform } from '/@/shared/types/types';
const BASE_QUERY_ARGS = { const BASE_QUERY_ARGS = {
limit: 15, limit: 30,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,
startIndex: 0, startIndex: 0,
}; };
@ -173,20 +176,255 @@ const HomeRoute = () => {
}), }),
); );
const isLoading = const starredAlbums = useQuery(
(random.isLoading && queriesEnabled[HomeItem.RANDOM]) || albumQueries.list({
(recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) || options: {
(recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) || enabled: queriesEnabled[HomeItem.STARRED_ALBUMS],
(recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) || staleTime: 1000 * 60 * 5,
(((isJellyfin && mostPlayedSongs.isLoading) || },
(!isJellyfin && mostPlayedAlbums.isLoading)) && 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]); queriesEnabled[HomeItem.MOST_PLAYED]);
if (isLoading) { if (isInitialLoading) {
return <Spinner container />; 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]: { [HomeItem.MOST_PLAYED]: {
data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items, data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items,
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM, itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
@ -217,6 +455,18 @@ const HomeRoute = () => {
query: recentlyReleased, query: recentlyReleased,
title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }), 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 const sortedCarousel = homeItems
@ -227,6 +477,10 @@ const HomeRoute = () => {
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) { if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false; return false;
} }
// Don't show flashback carousel if it has no data
if (item.id === HomeItem.FLASHBACK && !shouldShowFlashback) {
return false;
}
return true; return true;
}) })
@ -310,7 +564,11 @@ const HomeRoute = () => {
<Group> <Group>
<TextTitle order={3}>{carousel.title}</TextTitle> <TextTitle order={3}>{carousel.title}</TextTitle>
<ActionIcon <ActionIcon
onClick={() => carousel.query.refetch()} onClick={() =>
'onRefresh' in carousel
? carousel.onRefresh()
: carousel.query.refetch()
}
variant="transparent" variant="transparent"
> >
<Icon icon="refresh" /> <Icon icon="refresh" />

View file

@ -11,7 +11,15 @@ import { useMergedRef } from '@mantine/hooks';
import '@ag-grid-community/styles/ag-theme-alpine.css'; import '@ag-grid-community/styles/ag-theme-alpine.css';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import debounce from 'lodash/debounce'; 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 { ErrorBoundary } from 'react-error-boundary';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
@ -28,6 +36,7 @@ import {
useCurrentStatus, useCurrentStatus,
useDefaultQueue, useDefaultQueue,
usePlayerControls, usePlayerControls,
usePlayerStore,
usePreviousSong, usePreviousSong,
useQueueControls, useQueueControls,
useVolume, useVolume,
@ -53,9 +62,10 @@ type QueueProps = {
export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => { export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null); const tableRef = useRef<AgGridReactType | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedRef = useMergedRef(ref, tableRef); const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue(); const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls(); const { removeFromQueue, reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong(); const currentSong = useCurrentSong();
const previousSong = usePreviousSong(); const previousSong = usePreviousSong();
const status = useCurrentStatus(); const status = useCurrentStatus();
@ -257,8 +267,63 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS); 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 ( return (
<ErrorBoundary FallbackComponent={ErrorFallback}> <ErrorBoundary FallbackComponent={ErrorFallback}>
<div
ref={containerRef}
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}
tabIndex={0}
>
<VirtualGridAutoSizerContainer> <VirtualGridAutoSizerContainer>
<VirtualTable <VirtualTable
alwaysShowHorizontalScroll alwaysShowHorizontalScroll
@ -293,6 +358,7 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
suppressCellFocus={type === 'fullScreen'} suppressCellFocus={type === 'fullScreen'}
/> />
</VirtualGridAutoSizerContainer> </VirtualGridAutoSizerContainer>
</div>
</ErrorBoundary> </ErrorBoundary>
); );
}); });

View file

@ -38,3 +38,31 @@
background: var(--theme-overlay-header); background: var(--theme-overlay-header);
backdrop-filter: blur(var(--image-blur)); 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;
}
}

View file

@ -428,6 +428,11 @@ export const FullScreenPlayer = () => {
srcLoaded: true, 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 imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
const backgroundImage = const backgroundImage =
imageUrl && dynamicIsImage imageUrl && dynamicIsImage
@ -457,6 +462,14 @@ export const FullScreenPlayer = () => {
} }
/> />
)} )}
<div
className={styles.scanlineOverlay}
style={
{
'--album-color': scanlineColor,
} as CSSProperties
}
/>
<div className={styles.responsiveContainer}> <div className={styles.responsiveContainer}>
<FullScreenPlayerImage /> <FullScreenPlayerImage />
<FullScreenPlayerQueue /> <FullScreenPlayerQueue />

View file

@ -12,13 +12,19 @@
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
overflow: visible; overflow: visible;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
button { button {
display: flex; display: flex;
} }
&:focus-visible { &: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 { &:disabled {
@ -32,13 +38,24 @@
.player-button.active { .player-button.active {
svg { svg {
fill: var(--theme-colors-primary-filled); filter: drop-shadow(0 0 6px var(--theme-orange-transparent-50));
fill: var(--theme-orange-base);
} }
} }
.main { .main {
background: var(--theme-colors-foreground) !important; background: linear-gradient(
135deg,
var(--theme-orange-base),
var(--theme-orange-medium)
) !important;
border-radius: 50%; 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 { svg {
display: flex; display: flex;

View file

@ -1,7 +1,13 @@
.container { .container {
width: 100vw; width: 100vw;
height: 100%; 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 { .controls-grid {

View file

@ -36,6 +36,7 @@ import { PlaybackType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null; const remote = isElectron() ? window.api.remote : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export const RightControls = () => { export const RightControls = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -218,6 +219,46 @@ export const RightControls = () => {
)} )}
</Group> </Group>
<Group align="center" gap="xs" wrap="nowrap"> <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 arrowOffset={12} offset={0} position="top-end" width={425} withArrow>
<DropdownMenu.Target> <DropdownMenu.Target>
<ActionIcon <ActionIcon

View file

@ -2,11 +2,14 @@ import { DraggableItems } from '/@/renderer/features/settings/components/general
import { HomeItem, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store'; import { HomeItem, useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store';
const HOME_ITEMS: Array<[string, string]> = [ const HOME_ITEMS: Array<[string, string]> = [
[HomeItem.RANDOM, 'page.home.explore'], [HomeItem.FLASHBACK, 'page.home.flashback'],
[HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'], [HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'],
[HomeItem.RANDOM, 'page.home.explore'],
[HomeItem.RECENTLY_ADDED, 'page.home.newlyAdded'], [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.MOST_PLAYED, 'page.home.mostPlayed'],
[HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
]; ];
export const HomeSettings = () => { export const HomeSettings = () => {

View file

@ -161,6 +161,48 @@ export const ThemeSettings = () => {
}), }),
title: t('setting.accentColor', { postProcess: 'sentenceCase' }), 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} />; return <SettingsSection options={themeOptions} />;

View file

@ -32,6 +32,8 @@ export const getMpvSetting = (
value: any, value: any,
) => { ) => {
switch (key) { switch (key) {
case 'audioChannels':
return { 'audio-channels': value };
case 'audioExclusiveMode': case 'audioExclusiveMode':
return { 'audio-exclusive': value || 'no' }; return { 'audio-exclusive': value || 'no' };
case 'audioSampleRateHz': case 'audioSampleRateHz':
@ -53,6 +55,7 @@ export const getMpvSetting = (
export const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => { export const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => {
const properties: Record<string, any> = { const properties: Record<string, any> = {
'audio-channels': settings.audioChannels || 'stereo',
'audio-exclusive': settings.audioExclusiveMode || 'no', 'audio-exclusive': settings.audioExclusiveMode || 'no',
'audio-samplerate': 'audio-samplerate':
settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz, settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz,
@ -271,6 +274,22 @@ export const MpvSettings = () => {
isHidden: settings.type !== PlaybackType.LOCAL, isHidden: settings.type !== PlaybackType.LOCAL,
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }), 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: ( control: (
<NumberInput <NumberInput

View file

@ -49,6 +49,7 @@ export const Sidebar = () => {
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }), Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }), Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }), 'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }), Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }), Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }), 'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),

View file

@ -37,7 +37,7 @@ export function IsUpdatedDialog() {
<Group justify="flex-end" wrap="nowrap"> <Group justify="flex-end" wrap="nowrap">
<Button <Button
component="a" component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/v${version}`} href={`https://github.com/antebudimir/feishin/releases/tag/v${version}`}
onClick={handleDismiss} onClick={handleDismiss}
rightSection={<Icon icon="externalLink" />} rightSection={<Icon icon="externalLink" />}
target="_blank" target="_blank"

View file

@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route')); 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 SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route')); const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
@ -161,6 +163,11 @@ export const AppRouter = () => {
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_SONGS} path={AppRoute.LIBRARY_SONGS}
/> />
<Route
element={<FolderListRoute />}
errorElement={<RouteErrorBoundary />}
path={AppRoute.LIBRARY_FOLDERS}
/>
<Route <Route
element={<PlaylistListRoute />} element={<PlaylistListRoute />}
errorElement={<RouteErrorBoundary />} errorElement={<RouteErrorBoundary />}

View 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,
}));

View file

@ -28,11 +28,14 @@ import {
} from '/@/shared/types/types'; } from '/@/shared/types/types';
const HomeItemSchema = z.enum([ const HomeItemSchema = z.enum([
'flashback',
'mostPlayed', 'mostPlayed',
'random', 'random',
'recentlyAdded', 'recentlyAdded',
'recentlyPlayed', 'recentlyPlayed',
'recentlyReleased', 'recentlyReleased',
'starredAlbums',
'starredTracks',
]); ]);
const ArtistItemSchema = z.enum([ const ArtistItemSchema = z.enum([
@ -121,6 +124,7 @@ const TranscodingConfigSchema = z.object({
}); });
const MpvSettingsSchema = z.object({ const MpvSettingsSchema = z.object({
audioChannels: z.enum(['mono', 'stereo']).optional(),
audioExclusiveMode: z.enum(['no', 'yes']), audioExclusiveMode: z.enum(['no', 'yes']),
audioFormat: z.enum(['float', 's16', 's32']).optional(), audioFormat: z.enum(['float', 's16', 's32']).optional(),
audioSampleRateHz: z.number().optional(), audioSampleRateHz: z.number().optional(),
@ -177,6 +181,8 @@ const GeneralSettingsSchema = z.object({
buttonSize: z.number(), buttonSize: z.number(),
disabledContextMenu: z.record(z.boolean()), disabledContextMenu: z.record(z.boolean()),
doubleClickQueueAll: z.boolean(), doubleClickQueueAll: z.boolean(),
enableScanlineEffect: z.boolean(),
enableShimmerEffect: z.boolean(),
externalLinks: z.boolean(), externalLinks: z.boolean(),
followSystemTheme: z.boolean(), followSystemTheme: z.boolean(),
genreTarget: GenreTargetSchema, genreTarget: GenreTargetSchema,
@ -388,11 +394,14 @@ export enum GenreTarget {
} }
export enum HomeItem { export enum HomeItem {
FLASHBACK = 'flashback',
MOST_PLAYED = 'mostPlayed', MOST_PLAYED = 'mostPlayed',
RANDOM = 'random', RANDOM = 'random',
RECENTLY_ADDED = 'recentlyAdded', RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed', RECENTLY_PLAYED = 'recentlyPlayed',
RECENTLY_RELEASED = 'recentlyReleased', RECENTLY_RELEASED = 'recentlyReleased',
STARRED_ALBUMS = 'starredAlbums',
STARRED_TRACKS = 'starredTracks',
} }
export type DataTableProps = z.infer<typeof DataTablePropsSchema>; export type DataTableProps = z.infer<typeof DataTablePropsSchema>;
@ -475,6 +484,12 @@ export const sidebarItems: SidebarItemType[] = [
label: i18n.t('page.sidebar.genres'), label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES, route: AppRoute.LIBRARY_GENRES,
}, },
{
disabled: false,
id: 'Folders',
label: i18n.t('page.sidebar.folders'),
route: AppRoute.LIBRARY_FOLDERS,
},
{ {
disabled: true, disabled: true,
id: 'Playlists', id: 'Playlists',
@ -489,10 +504,16 @@ export const sidebarItems: SidebarItemType[] = [
}, },
]; ];
const homeItems = Object.values(HomeItem).map((item) => ({ const homeItems: SortableItem<HomeItem>[] = [
disabled: false, { disabled: false, id: HomeItem.FLASHBACK },
id: item, { 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) => ({ const artistItems = Object.values(ArtistItem).map((item) => ({
disabled: false, disabled: false,
@ -524,30 +545,32 @@ const initialState: SettingsState = {
font: { font: {
builtIn: 'Poppins', builtIn: 'Poppins',
custom: null, custom: null,
system: null, system: 'Noto Sans Regular',
type: FontType.BUILT_IN, type: FontType.SYSTEM,
}, },
general: { general: {
accent: 'rgb(53, 116, 252)', accent: 'rgb(255, 142, 83)',
albumArtRes: undefined, albumArtRes: undefined,
albumBackground: false, albumBackground: true,
albumBackgroundBlur: 6, albumBackgroundBlur: 50,
artistBackground: false, artistBackground: false,
artistBackgroundBlur: 6, artistBackgroundBlur: 6,
artistItems, artistItems,
buttonSize: 15, buttonSize: 25,
disabledContextMenu: {}, disabledContextMenu: {},
doubleClickQueueAll: true, doubleClickQueueAll: false,
enableScanlineEffect: true,
enableShimmerEffect: true,
externalLinks: true, externalLinks: true,
followSystemTheme: false, followSystemTheme: false,
genreTarget: GenreTarget.TRACK, genreTarget: GenreTarget.ALBUM,
homeFeature: true, homeFeature: true,
homeItems, homeItems,
language: 'en', language: 'en',
lastFM: true, lastFM: true,
lastfmApiKey: '', lastfmApiKey: '',
musicBrainz: true, musicBrainz: true,
nativeAspectRatio: false, nativeAspectRatio: true,
passwordStore: undefined, passwordStore: undefined,
playButtonBehavior: Play.NOW, playButtonBehavior: Play.NOW,
playerbarOpenDrawer: false, playerbarOpenDrawer: false,
@ -557,18 +580,18 @@ const initialState: SettingsState = {
sidebarCollapseShared: false, sidebarCollapseShared: false,
sidebarItems, sidebarItems,
sidebarPlaylistList: true, sidebarPlaylistList: true,
sideQueueType: 'sideQueue', sideQueueType: 'sideDrawerQueue',
skipButtons: { skipButtons: {
enabled: false, enabled: true,
skipBackwardSeconds: 5, skipBackwardSeconds: 5,
skipForwardSeconds: 10, skipForwardSeconds: 5,
}, },
theme: AppTheme.DEFAULT_DARK, theme: AppTheme.DEFAULT_DARK,
themeDark: AppTheme.DEFAULT_DARK, themeDark: AppTheme.DEFAULT_DARK,
themeLight: AppTheme.DEFAULT_LIGHT, themeLight: AppTheme.DEFAULT_LIGHT,
volumeWheelStep: 5, volumeWheelStep: 5,
volumeWidth: 70, volumeWidth: 200,
zoomFactor: 100, zoomFactor: 145,
}, },
hotkeys: { hotkeys: {
bindings: { bindings: {
@ -614,7 +637,7 @@ const initialState: SettingsState = {
delayMs: 0, delayMs: 0,
enableAutoTranslation: false, enableAutoTranslation: false,
enableNeteaseTranslation: false, enableNeteaseTranslation: false,
fetch: false, fetch: true,
follow: true, follow: true,
fontSize: 24, fontSize: 24,
fontSizeUnsync: 24, fontSizeUnsync: 24,
@ -630,14 +653,15 @@ const initialState: SettingsState = {
}, },
playback: { playback: {
audioDeviceId: undefined, audioDeviceId: undefined,
crossfadeDuration: 5, crossfadeDuration: 75,
crossfadeStyle: CrossfadeStyle.EQUALPOWER, crossfadeStyle: CrossfadeStyle.DIPPED,
mediaSession: false, mediaSession: false,
mpvExtraParameters: [], mpvExtraParameters: [],
mpvProperties: { mpvProperties: {
audioChannels: 'stereo',
audioExclusiveMode: 'no', audioExclusiveMode: 'no',
audioFormat: undefined, audioFormat: undefined,
audioSampleRateHz: 0, audioSampleRateHz: 48000,
gaplessAudio: 'weak', gaplessAudio: 'weak',
replayGainClip: true, replayGainClip: true,
replayGainFallbackDB: undefined, replayGainFallbackDB: undefined,
@ -656,11 +680,11 @@ const initialState: SettingsState = {
transcode: { transcode: {
enabled: false, enabled: false,
}, },
type: PlaybackType.WEB, type: PlaybackType.LOCAL,
webAudio: true, webAudio: true,
}, },
remote: { remote: {
enabled: false, enabled: true,
password: randomString(8), password: randomString(8),
port: 4333, port: 4333,
username: 'feishin', username: 'feishin',

View file

@ -6,19 +6,6 @@ import merge from 'lodash/merge';
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types'; import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
// const lightColors: MantineColorsTuple = [
// '#f5f5f5',
// '#e7e7e7',
// '#cdcdcd',
// '#b2b2b2',
// '#9a9a9a',
// '#8b8b8b',
// '#848484',
// '#717171',
// '#656565',
// '#575757',
// ];
const darkColors: MantineColorsTuple = [ const darkColors: MantineColorsTuple = [
'#C9C9C9', '#C9C9C9',
'#b8b8b8', '#b8b8b8',

View file

@ -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 = { export const ssNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
folderItem: normalizeFolderItem,
genre: normalizeGenre, genre: normalizeGenre,
playlist: normalizePlaylist, playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,

View file

@ -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 = { export const ssType = {
_parameters: { _parameters: {
albumInfo: albumInfoParameters, albumInfo: albumInfoParameters,
@ -563,6 +630,8 @@ export const ssType = {
getArtists: getArtistsParameters, getArtists: getArtistsParameters,
getGenre: getGenresParameters, getGenre: getGenresParameters,
getGenres: getGenresParameters, getGenres: getGenresParameters,
getIndexes: getIndexesParameters,
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters, getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters, getPlaylists: getPlaylistsParameters,
getSong: getSongParameters, getSong: getSongParameters,
@ -591,12 +660,16 @@ export const ssType = {
baseResponse, baseResponse,
createFavorite, createFavorite,
createPlaylist, createPlaylist,
directory,
directoryChild,
genre, genre,
getAlbum, getAlbum,
getAlbumList2, getAlbumList2,
getArtist, getArtist,
getArtists, getArtists,
getGenres, getGenres,
getIndexes,
getMusicDirectory,
getPlaylist, getPlaylist,
getPlaylists, getPlaylists,
getSong, getSong,

View file

@ -33,6 +33,50 @@ html {
background: var(--theme-colors-background); 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, input,
button, button,
textarea, textarea,
@ -58,6 +102,8 @@ img {
} }
#app { #app {
position: relative;
z-index: 10;
height: inherit; 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-face {
font-family: Archivo; font-family: Archivo;
font-weight: 100 1000; font-weight: 100 1000;
@ -224,6 +302,48 @@ button {
:root { :root {
--theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4='); --theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');
--theme-fullscreen-player-text-shadow: black 0px 0px 10px; --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-xs: var(--mantine-font-size-xs);
--theme-font-size-sm: var(--mantine-font-size-sm); --theme-font-size-sm: var(--mantine-font-size-sm);
--theme-font-size-md: var(--mantine-font-size-md); --theme-font-size-md: var(--mantine-font-size-md);

View file

@ -7,10 +7,10 @@ export const defaultTheme: AppThemeConfiguration = {
'overlay-subheader': 'overlay-subheader':
'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)', 'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
'root-font-size': '16px', 'root-font-size': '16px',
'scrollbar-handle-active-background': 'rgba(160, 160, 160, 60%)', 'scrollbar-handle-active-background': 'rgba(255, 142, 83, 0.7)',
'scrollbar-handle-background': 'rgba(160, 160, 160, 30%)', 'scrollbar-handle-background': 'rgba(255, 142, 83, 0.4)',
'scrollbar-handle-border-radius': '0px', 'scrollbar-handle-border-radius': '0.3rem',
'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 60%)', 'scrollbar-handle-hover-background': 'rgba(255, 142, 83, 0.9)',
'scrollbar-size': '12px', 'scrollbar-size': '12px',
'scrollbar-track-active-background': 'transparent', 'scrollbar-track-active-background': 'transparent',
'scrollbar-track-background': 'transparent', 'scrollbar-track-background': 'transparent',
@ -18,17 +18,18 @@ export const defaultTheme: AppThemeConfiguration = {
'scrollbar-track-hover-background': 'transparent', 'scrollbar-track-hover-background': 'transparent',
}, },
colors: { colors: {
background: 'rgb(16, 16, 16)', background: 'rgb(2, 26, 26)',
'background-alternate': 'rgb(0, 0, 0)', 'background-alternate': 'rgb(19, 16, 16)',
black: 'rgb(0, 0, 0)', black: 'rgb(0, 0, 0)',
foreground: 'rgb(225, 225, 225)', foreground: 'rgb(240, 240, 240)',
'foreground-muted': 'rgb(150, 150, 150)', 'foreground-muted': 'rgb(187, 187, 187)',
'state-error': 'rgb(204, 50, 50)', primary: 'rgb(255, 142, 83)',
'state-info': 'rgb(53, 116, 252)', 'state-error': 'rgb(255, 0, 0)',
'state-success': 'rgb(50, 204, 50)', 'state-info': 'rgb(0, 183, 255)',
'state-warning': 'rgb(255, 120, 120)', 'state-success': 'rgb(0, 255, 255)',
surface: 'rgb(24, 24, 24)', 'state-warning': 'rgb(255, 142, 83)',
'surface-foreground': 'rgb(215, 215, 215)', surface: 'rgba(2, 26, 26, 0.8)',
'surface-foreground': 'rgb(240, 240, 240)',
white: 'rgb(255, 255, 255)', white: 'rgb(255, 255, 255)',
}, },
mode: 'dark', mode: 'dark',

View file

@ -31,6 +31,7 @@ export enum LibraryItem {
ALBUM = 'album', ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist', ARTIST = 'artist',
FOLDER = 'folder',
GENRE = 'genre', GENRE = 'genre',
PLAYLIST = 'playlist', PLAYLIST = 'playlist',
SONG = 'song', SONG = 'song',
@ -255,6 +256,47 @@ export type EndpointDetails = {
server: ServerListItem; 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 = { export type GainInfo = {
album?: number; album?: number;
track?: number; track?: number;
@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>; getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>; getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getDownloadUrl: (args: DownloadArgs) => string; getDownloadUrl: (args: DownloadArgs) => string;
getFolderList: (args: FolderListArgs) => Promise<FolderListResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>; getLyrics?: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>; getArtistList: (args: ReplaceApiClientProps<ArtistListArgs>) => Promise<ArtistListResponse>;
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>; getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string; getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolderList: (args: ReplaceApiClientProps<FolderListArgs>) => Promise<FolderListResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>; getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>; getLyrics?: (args: ReplaceApiClientProps<LyricsArgs>) => Promise<LyricsResponse>;
getMusicFolderList: ( getMusicFolderList: (