diff --git a/src/renderer/features/player/components/player-button.module.css b/src/renderer/features/player/components/player-button.module.css
index d74dabc2..b9541f99 100644
--- a/src/renderer/features/player/components/player-button.module.css
+++ b/src/renderer/features/player/components/player-button.module.css
@@ -12,13 +12,19 @@
width: 100%;
padding: 0.5rem;
overflow: visible;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
button {
display: flex;
}
&:focus-visible {
- outline: 1px var(--theme-colors-primary-filled) solid;
+ outline: 2px var(--theme-orange-base) solid;
+ box-shadow: var(--theme-shadow-orange-glow-soft);
+ }
+
+ &:hover {
+ filter: drop-shadow(0 0 8px var(--theme-orange-transparent-40));
}
&:disabled {
@@ -32,13 +38,24 @@
.player-button.active {
svg {
- fill: var(--theme-colors-primary-filled);
+ filter: drop-shadow(0 0 6px var(--theme-orange-transparent-50));
+ fill: var(--theme-orange-base);
}
}
.main {
- background: var(--theme-colors-foreground) !important;
+ background: linear-gradient(
+ 135deg,
+ var(--theme-orange-base),
+ var(--theme-orange-medium)
+ ) !important;
border-radius: 50%;
+ box-shadow: var(--theme-shadow-orange-glow-soft);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ &:hover {
+ box-shadow: var(--theme-shadow-orange-glow-medium);
+ }
svg {
display: flex;
diff --git a/src/renderer/features/player/components/playerbar.module.css b/src/renderer/features/player/components/playerbar.module.css
index e1566402..9f5d3ef4 100644
--- a/src/renderer/features/player/components/playerbar.module.css
+++ b/src/renderer/features/player/components/playerbar.module.css
@@ -1,7 +1,13 @@
.container {
width: 100vw;
height: 100%;
- border-top: var(--theme-colors-border);
+ background: linear-gradient(
+ 180deg,
+ var(--theme-colors-background) 0%,
+ rgb(2 26 26 / 95%) 100%
+ );
+ border-top: 2px solid var(--theme-orange-transparent-40);
+ box-shadow: 0 -4px 12px var(--theme-orange-transparent-15);
}
.controls-grid {
diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx
index cf2bed67..5ba43340 100644
--- a/src/renderer/features/player/components/right-controls.tsx
+++ b/src/renderer/features/player/components/right-controls.tsx
@@ -36,6 +36,7 @@ import { PlaybackType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null;
+const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export const RightControls = () => {
const { t } = useTranslation();
@@ -218,6 +219,46 @@ export const RightControls = () => {
)}
+ {(playbackType === PlaybackType.LOCAL || playbackType === PlaybackType.WEB) && (
+ {
+ 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"
+ />
+ )}
= [
- [HomeItem.RANDOM, 'page.home.explore'],
+ [HomeItem.FLASHBACK, 'page.home.flashback'],
[HomeItem.RECENTLY_PLAYED, 'page.home.recentlyPlayed'],
+ [HomeItem.RANDOM, 'page.home.explore'],
[HomeItem.RECENTLY_ADDED, 'page.home.newlyAdded'],
- [HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
+ [HomeItem.STARRED_ALBUMS, 'page.home.starredAlbums'],
+ [HomeItem.STARRED_TRACKS, 'page.home.starredTracks'],
[HomeItem.MOST_PLAYED, 'page.home.mostPlayed'],
+ [HomeItem.RECENTLY_RELEASED, 'page.home.recentlyReleased'],
];
export const HomeSettings = () => {
diff --git a/src/renderer/features/settings/components/general/theme-settings.tsx b/src/renderer/features/settings/components/general/theme-settings.tsx
index 9261f3d5..85148815 100644
--- a/src/renderer/features/settings/components/general/theme-settings.tsx
+++ b/src/renderer/features/settings/components/general/theme-settings.tsx
@@ -161,6 +161,48 @@ export const ThemeSettings = () => {
}),
title: t('setting.accentColor', { postProcess: 'sentenceCase' }),
},
+ {
+ control: (
+ {
+ setSettings({
+ general: {
+ ...settings,
+ enableShimmerEffect: e.currentTarget.checked,
+ },
+ });
+ }}
+ />
+ ),
+ description: t('setting.enableShimmerEffect', {
+ context: 'description',
+ postProcess: 'sentenceCase',
+ }),
+ isHidden: false,
+ title: t('setting.enableShimmerEffect', { postProcess: 'sentenceCase' }),
+ },
+ {
+ control: (
+ {
+ setSettings({
+ general: {
+ ...settings,
+ enableScanlineEffect: e.currentTarget.checked,
+ },
+ });
+ }}
+ />
+ ),
+ description: t('setting.enableScanlineEffect', {
+ context: 'description',
+ postProcess: 'sentenceCase',
+ }),
+ isHidden: false,
+ title: t('setting.enableScanlineEffect', { postProcess: 'sentenceCase' }),
+ },
];
return ;
diff --git a/src/renderer/features/settings/components/playback/mpv-settings.tsx b/src/renderer/features/settings/components/playback/mpv-settings.tsx
index 354e07d7..e660ccea 100644
--- a/src/renderer/features/settings/components/playback/mpv-settings.tsx
+++ b/src/renderer/features/settings/components/playback/mpv-settings.tsx
@@ -32,6 +32,8 @@ export const getMpvSetting = (
value: any,
) => {
switch (key) {
+ case 'audioChannels':
+ return { 'audio-channels': value };
case 'audioExclusiveMode':
return { 'audio-exclusive': value || 'no' };
case 'audioSampleRateHz':
@@ -53,6 +55,7 @@ export const getMpvSetting = (
export const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => {
const properties: Record = {
+ 'audio-channels': settings.audioChannels || 'stereo',
'audio-exclusive': settings.audioExclusiveMode || 'no',
'audio-samplerate':
settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz,
@@ -271,6 +274,22 @@ export const MpvSettings = () => {
isHidden: settings.type !== PlaybackType.LOCAL,
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),
},
+ {
+ control: (
+ 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: (
{
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
+ Folders: t('page.sidebar.folders', { postProcess: 'titleCase' }),
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
diff --git a/src/renderer/is-updated-dialog.tsx b/src/renderer/is-updated-dialog.tsx
index 02b10266..68e3477e 100644
--- a/src/renderer/is-updated-dialog.tsx
+++ b/src/renderer/is-updated-dialog.tsx
@@ -37,7 +37,7 @@ export function IsUpdatedDialog() {
}
target="_blank"
diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx
index 52379720..afe74390 100644
--- a/src/renderer/router/app-router.tsx
+++ b/src/renderer/router/app-router.tsx
@@ -59,6 +59,8 @@ const DummyAlbumDetailRoute = lazy(
const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route'));
+const FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route'));
+
const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route'));
const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route'));
@@ -161,6 +163,11 @@ export const AppRouter = () => {
errorElement={ }
path={AppRoute.LIBRARY_SONGS}
/>
+ }
+ errorElement={ }
+ path={AppRoute.LIBRARY_FOLDERS}
+ />
}
errorElement={ }
diff --git a/src/renderer/store/folder.store.ts b/src/renderer/store/folder.store.ts
new file mode 100644
index 00000000..51df49bc
--- /dev/null
+++ b/src/renderer/store/folder.store.ts
@@ -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()(
+ 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,
+ }));
diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts
index e867093f..8cff7098 100644
--- a/src/renderer/store/settings.store.ts
+++ b/src/renderer/store/settings.store.ts
@@ -28,11 +28,14 @@ import {
} from '/@/shared/types/types';
const HomeItemSchema = z.enum([
+ 'flashback',
'mostPlayed',
'random',
'recentlyAdded',
'recentlyPlayed',
'recentlyReleased',
+ 'starredAlbums',
+ 'starredTracks',
]);
const ArtistItemSchema = z.enum([
@@ -121,6 +124,7 @@ const TranscodingConfigSchema = z.object({
});
const MpvSettingsSchema = z.object({
+ audioChannels: z.enum(['mono', 'stereo']).optional(),
audioExclusiveMode: z.enum(['no', 'yes']),
audioFormat: z.enum(['float', 's16', 's32']).optional(),
audioSampleRateHz: z.number().optional(),
@@ -177,6 +181,8 @@ const GeneralSettingsSchema = z.object({
buttonSize: z.number(),
disabledContextMenu: z.record(z.boolean()),
doubleClickQueueAll: z.boolean(),
+ enableScanlineEffect: z.boolean(),
+ enableShimmerEffect: z.boolean(),
externalLinks: z.boolean(),
followSystemTheme: z.boolean(),
genreTarget: GenreTargetSchema,
@@ -388,11 +394,14 @@ export enum GenreTarget {
}
export enum HomeItem {
+ FLASHBACK = 'flashback',
MOST_PLAYED = 'mostPlayed',
RANDOM = 'random',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
RECENTLY_RELEASED = 'recentlyReleased',
+ STARRED_ALBUMS = 'starredAlbums',
+ STARRED_TRACKS = 'starredTracks',
}
export type DataTableProps = z.infer;
@@ -475,6 +484,12 @@ export const sidebarItems: SidebarItemType[] = [
label: i18n.t('page.sidebar.genres'),
route: AppRoute.LIBRARY_GENRES,
},
+ {
+ disabled: false,
+ id: 'Folders',
+ label: i18n.t('page.sidebar.folders'),
+ route: AppRoute.LIBRARY_FOLDERS,
+ },
{
disabled: true,
id: 'Playlists',
@@ -489,10 +504,16 @@ export const sidebarItems: SidebarItemType[] = [
},
];
-const homeItems = Object.values(HomeItem).map((item) => ({
- disabled: false,
- id: item,
-}));
+const homeItems: SortableItem[] = [
+ { disabled: false, id: HomeItem.FLASHBACK },
+ { disabled: false, id: HomeItem.RECENTLY_PLAYED },
+ { disabled: false, id: HomeItem.RANDOM },
+ { disabled: false, id: HomeItem.RECENTLY_ADDED },
+ { disabled: false, id: HomeItem.STARRED_ALBUMS },
+ { disabled: false, id: HomeItem.STARRED_TRACKS },
+ { disabled: false, id: HomeItem.MOST_PLAYED },
+ { disabled: true, id: HomeItem.RECENTLY_RELEASED },
+];
const artistItems = Object.values(ArtistItem).map((item) => ({
disabled: false,
@@ -524,30 +545,32 @@ const initialState: SettingsState = {
font: {
builtIn: 'Poppins',
custom: null,
- system: null,
- type: FontType.BUILT_IN,
+ system: 'Noto Sans Regular',
+ type: FontType.SYSTEM,
},
general: {
- accent: 'rgb(53, 116, 252)',
+ accent: 'rgb(255, 142, 83)',
albumArtRes: undefined,
- albumBackground: false,
- albumBackgroundBlur: 6,
+ albumBackground: true,
+ albumBackgroundBlur: 50,
artistBackground: false,
artistBackgroundBlur: 6,
artistItems,
- buttonSize: 15,
+ buttonSize: 25,
disabledContextMenu: {},
- doubleClickQueueAll: true,
+ doubleClickQueueAll: false,
+ enableScanlineEffect: true,
+ enableShimmerEffect: true,
externalLinks: true,
followSystemTheme: false,
- genreTarget: GenreTarget.TRACK,
+ genreTarget: GenreTarget.ALBUM,
homeFeature: true,
homeItems,
language: 'en',
lastFM: true,
lastfmApiKey: '',
musicBrainz: true,
- nativeAspectRatio: false,
+ nativeAspectRatio: true,
passwordStore: undefined,
playButtonBehavior: Play.NOW,
playerbarOpenDrawer: false,
@@ -557,18 +580,18 @@ const initialState: SettingsState = {
sidebarCollapseShared: false,
sidebarItems,
sidebarPlaylistList: true,
- sideQueueType: 'sideQueue',
+ sideQueueType: 'sideDrawerQueue',
skipButtons: {
- enabled: false,
+ enabled: true,
skipBackwardSeconds: 5,
- skipForwardSeconds: 10,
+ skipForwardSeconds: 5,
},
theme: AppTheme.DEFAULT_DARK,
themeDark: AppTheme.DEFAULT_DARK,
themeLight: AppTheme.DEFAULT_LIGHT,
volumeWheelStep: 5,
- volumeWidth: 70,
- zoomFactor: 100,
+ volumeWidth: 200,
+ zoomFactor: 145,
},
hotkeys: {
bindings: {
@@ -614,7 +637,7 @@ const initialState: SettingsState = {
delayMs: 0,
enableAutoTranslation: false,
enableNeteaseTranslation: false,
- fetch: false,
+ fetch: true,
follow: true,
fontSize: 24,
fontSizeUnsync: 24,
@@ -630,14 +653,15 @@ const initialState: SettingsState = {
},
playback: {
audioDeviceId: undefined,
- crossfadeDuration: 5,
- crossfadeStyle: CrossfadeStyle.EQUALPOWER,
+ crossfadeDuration: 75,
+ crossfadeStyle: CrossfadeStyle.DIPPED,
mediaSession: false,
mpvExtraParameters: [],
mpvProperties: {
+ audioChannels: 'stereo',
audioExclusiveMode: 'no',
audioFormat: undefined,
- audioSampleRateHz: 0,
+ audioSampleRateHz: 48000,
gaplessAudio: 'weak',
replayGainClip: true,
replayGainFallbackDB: undefined,
@@ -656,11 +680,11 @@ const initialState: SettingsState = {
transcode: {
enabled: false,
},
- type: PlaybackType.WEB,
+ type: PlaybackType.LOCAL,
webAudio: true,
},
remote: {
- enabled: false,
+ enabled: true,
password: randomString(8),
port: 4333,
username: 'feishin',
diff --git a/src/renderer/themes/mantine-theme.tsx b/src/renderer/themes/mantine-theme.tsx
index 0210b877..aa8e7dd1 100644
--- a/src/renderer/themes/mantine-theme.tsx
+++ b/src/renderer/themes/mantine-theme.tsx
@@ -6,19 +6,6 @@ import merge from 'lodash/merge';
import { AppThemeConfiguration } from '/@/shared/themes/app-theme-types';
-// const lightColors: MantineColorsTuple = [
-// '#f5f5f5',
-// '#e7e7e7',
-// '#cdcdcd',
-// '#b2b2b2',
-// '#9a9a9a',
-// '#8b8b8b',
-// '#848484',
-// '#717171',
-// '#656565',
-// '#575757',
-// ];
-
const darkColors: MantineColorsTuple = [
'#C9C9C9',
'#b8b8b8',
diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts
index 58904124..6205162b 100644
--- a/src/shared/api/subsonic/subsonic-normalize.ts
+++ b/src/shared/api/subsonic/subsonic-normalize.ts
@@ -334,9 +334,51 @@ const normalizeGenre = (item: z.infer): Genre =>
};
};
+const normalizeFolderItem = (
+ item: z.infer,
+ server?: null | ServerListItemWithCredential,
+): import('/@/shared/types/domain-types').FolderItem => {
+ const imageUrl =
+ getCoverArtUrl({
+ baseUrl: server?.url,
+ coverArtId: item.coverArt?.toString(),
+ credential: server?.credential,
+ size: 300,
+ }) || null;
+
+ return {
+ album: item.album,
+ albumId: item.albumId?.toString(),
+ artist: item.artist,
+ artistId: item.artistId?.toString(),
+ coverArt: item.coverArt?.toString(),
+ created: item.created,
+ duration: item.duration,
+ genre: item.genre,
+ id: item.id.toString(),
+ imageUrl,
+ isDir: item.isDir,
+ itemType: LibraryItem.FOLDER,
+ name: item.title,
+ parent: item.parent,
+ path: item.path,
+ playCount: item.playCount,
+ serverId: server?.id || 'unknown',
+ serverType: ServerType.SUBSONIC,
+ size: item.size,
+ starred: !!item.starred,
+ suffix: item.suffix,
+ title: item.title,
+ track: item.track,
+ userRating: item.userRating,
+ year: item.year,
+ };
+};
+
export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
+ folderItem: normalizeFolderItem,
genre: normalizeGenre,
playlist: normalizePlaylist,
song: normalizeSong,
diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts
index 4be3577c..d3c44630 100644
--- a/src/shared/api/subsonic/subsonic-types.ts
+++ b/src/shared/api/subsonic/subsonic-types.ts
@@ -548,6 +548,73 @@ const albumInfo = z.object({
}),
});
+const directoryChild = z.object({
+ album: z.string().optional(),
+ albumId: id.optional(),
+ artist: z.string().optional(),
+ artistId: id.optional(),
+ averageRating: z.number().optional(),
+ contentType: z.string().optional(),
+ coverArt: z.string().optional(),
+ created: z.string().optional(),
+ duration: z.number().optional(),
+ genre: z.string().optional(),
+ id,
+ isDir: z.boolean(),
+ isVideo: z.boolean().optional(),
+ parent: z.string().optional(),
+ path: z.string().optional(),
+ playCount: z.number().optional(),
+ size: z.number().optional(),
+ starred: z.string().optional(),
+ suffix: z.string().optional(),
+ title: z.string(),
+ track: z.number().optional(),
+ type: z.string().optional(),
+ userRating: z.number().optional(),
+ year: z.number().optional(),
+});
+
+const directory = z.object({
+ averageRating: z.number().optional(),
+ child: z.array(directoryChild).optional(),
+ id,
+ name: z.string(),
+ parent: z.string().optional(),
+ playCount: z.number().optional(),
+ starred: z.string().optional(),
+ userRating: z.number().optional(),
+});
+
+const getMusicDirectoryParameters = z.object({
+ id: z.string(),
+});
+
+const getMusicDirectory = z.object({
+ directory,
+});
+
+const getIndexesParameters = z.object({
+ ifModifiedSince: z.number().optional(),
+ musicFolderId: z.string().optional(),
+});
+
+const getIndexes = z.object({
+ indexes: z.object({
+ ignoredArticles: z.string().optional(),
+ index: z
+ .array(
+ z.object({
+ artist: z.array(artistListEntry).optional(),
+ name: z.string(),
+ }),
+ )
+ .optional(),
+ lastModified: z.number(),
+ shortcut: z.array(artistListEntry).optional(),
+ }),
+});
+
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@@ -563,6 +630,8 @@ export const ssType = {
getArtists: getArtistsParameters,
getGenre: getGenresParameters,
getGenres: getGenresParameters,
+ getIndexes: getIndexesParameters,
+ getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getSong: getSongParameters,
@@ -591,12 +660,16 @@ export const ssType = {
baseResponse,
createFavorite,
createPlaylist,
+ directory,
+ directoryChild,
genre,
getAlbum,
getAlbumList2,
getArtist,
getArtists,
getGenres,
+ getIndexes,
+ getMusicDirectory,
getPlaylist,
getPlaylists,
getSong,
diff --git a/src/shared/styles/global.css b/src/shared/styles/global.css
index c9de9195..d99b3dbb 100644
--- a/src/shared/styles/global.css
+++ b/src/shared/styles/global.css
@@ -33,6 +33,50 @@ html {
background: var(--theme-colors-background);
}
+body.enable-shimmer::before,
+html.enable-shimmer::before {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ content: '';
+ background: linear-gradient(
+ 105deg,
+ transparent 40%,
+ rgb(0 183 255 / 10%) 45%,
+ rgb(0 183 255 / 20%) 50%,
+ rgb(0 183 255 / 10%) 55%,
+ transparent 60%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 8s infinite;
+}
+
+body.enable-scanline::after,
+html.enable-scanline::after {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ content: '';
+ background: linear-gradient(
+ 0deg,
+ transparent 0%,
+ rgb(0 255 255 / 3%) 25%,
+ transparent 50%,
+ rgb(0 255 255 / 3%) 75%,
+ transparent 100%
+ );
+ background-size: 100% 200%;
+ animation: scanline 6s linear infinite;
+}
+
input,
button,
textarea,
@@ -58,6 +102,8 @@ img {
}
#app {
+ position: relative;
+ z-index: 10;
height: inherit;
}
@@ -120,6 +166,38 @@ button {
}
}
+
+@keyframes float {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: 0% 0;
+ }
+
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+@keyframes scanline {
+ 0% {
+ background-position: 0 -100vh;
+ }
+
+ 100% {
+ background-position: 0 100vh;
+ }
+}
+
@font-face {
font-family: Archivo;
font-weight: 100 1000;
@@ -224,6 +302,48 @@ button {
:root {
--theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');
--theme-fullscreen-player-text-shadow: black 0px 0px 10px;
+ --theme-orange-base: rgb(255 142 83);
+ --theme-orange-medium: rgb(255 123 52);
+ --theme-orange-dark: rgb(255 89 0);
+ --theme-orange-transparent-70: rgb(255 142 83 / 70%);
+ --theme-orange-transparent-40: rgb(255 142 83 / 40%);
+ --theme-orange-transparent-30: rgb(255 142 83 / 30%);
+ --theme-orange-transparent-20: rgb(255 142 83 / 20%);
+ --theme-orange-transparent-15: rgb(255 142 83 / 15%);
+ --theme-orange-transparent-10: rgb(255 142 83 / 10%);
+ --theme-cyan-primary: rgb(0 183 255);
+ --theme-cyan-secondary: rgb(0 255 255);
+ --theme-cyan-transparent-80: rgb(0 183 255 / 80%);
+ --theme-cyan-transparent-70: rgb(0 183 255 / 70%);
+ --theme-cyan-transparent-60: rgb(0 183 255 / 60%);
+ --theme-cyan-transparent-50: rgb(0 183 255 / 50%);
+ --theme-cyan-transparent-40: rgb(0 183 255 / 40%);
+ --theme-cyan-transparent-30: rgb(0 183 255 / 30%);
+ --theme-cyan-transparent-20: rgb(0 183 255 / 20%);
+ --theme-cyan-transparent-10: rgb(0 183 255 / 10%);
+ --theme-cyan-transparent-05: rgb(0 183 255 / 5%);
+ --theme-cyan-transparent-03: rgb(0 183 255 / 3%);
+
+ /* Gradients */
+ --theme-primary-gradient: linear-gradient(
+ 45deg,
+ var(--theme-orange-dark),
+ var(--theme-orange-medium),
+ var(--theme-orange-base)
+ );
+ --theme-cyan-gradient: linear-gradient(
+ 90deg,
+ var(--theme-cyan-primary),
+ var(--theme-cyan-secondary)
+ );
+
+ /* Shadows */
+ --theme-shadow-cyan-glow: 0 0 10px var(--theme-cyan-transparent-30);
+ --theme-shadow-cyan-glow-medium: 0 0 15px var(--theme-cyan-transparent-20);
+ --theme-shadow-orange-glow-soft: 0 4px 12px var(--theme-orange-transparent-15);
+ --theme-shadow-orange-glow-medium:
+ 0 6px 20px var(--theme-orange-transparent-30), 0 0 20px var(--theme-orange-transparent-20);
+ --theme-text-shadow-cyan: 0 0 20px var(--theme-cyan-transparent-30);
--theme-font-size-xs: var(--mantine-font-size-xs);
--theme-font-size-sm: var(--mantine-font-size-sm);
--theme-font-size-md: var(--mantine-font-size-md);
diff --git a/src/shared/themes/default.ts b/src/shared/themes/default.ts
index c698cc96..be3c42db 100644
--- a/src/shared/themes/default.ts
+++ b/src/shared/themes/default.ts
@@ -7,10 +7,10 @@ export const defaultTheme: AppThemeConfiguration = {
'overlay-subheader':
'linear-gradient(180deg, rgb(0 0 0 / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)',
'root-font-size': '16px',
- 'scrollbar-handle-active-background': 'rgba(160, 160, 160, 60%)',
- 'scrollbar-handle-background': 'rgba(160, 160, 160, 30%)',
- 'scrollbar-handle-border-radius': '0px',
- 'scrollbar-handle-hover-background': 'rgba(160, 160, 160, 60%)',
+ 'scrollbar-handle-active-background': 'rgba(255, 142, 83, 0.7)',
+ 'scrollbar-handle-background': 'rgba(255, 142, 83, 0.4)',
+ 'scrollbar-handle-border-radius': '0.3rem',
+ 'scrollbar-handle-hover-background': 'rgba(255, 142, 83, 0.9)',
'scrollbar-size': '12px',
'scrollbar-track-active-background': 'transparent',
'scrollbar-track-background': 'transparent',
@@ -18,17 +18,18 @@ export const defaultTheme: AppThemeConfiguration = {
'scrollbar-track-hover-background': 'transparent',
},
colors: {
- background: 'rgb(16, 16, 16)',
- 'background-alternate': 'rgb(0, 0, 0)',
+ background: 'rgb(2, 26, 26)',
+ 'background-alternate': 'rgb(19, 16, 16)',
black: 'rgb(0, 0, 0)',
- foreground: 'rgb(225, 225, 225)',
- 'foreground-muted': 'rgb(150, 150, 150)',
- 'state-error': 'rgb(204, 50, 50)',
- 'state-info': 'rgb(53, 116, 252)',
- 'state-success': 'rgb(50, 204, 50)',
- 'state-warning': 'rgb(255, 120, 120)',
- surface: 'rgb(24, 24, 24)',
- 'surface-foreground': 'rgb(215, 215, 215)',
+ foreground: 'rgb(240, 240, 240)',
+ 'foreground-muted': 'rgb(187, 187, 187)',
+ primary: 'rgb(255, 142, 83)',
+ 'state-error': 'rgb(255, 0, 0)',
+ 'state-info': 'rgb(0, 183, 255)',
+ 'state-success': 'rgb(0, 255, 255)',
+ 'state-warning': 'rgb(255, 142, 83)',
+ surface: 'rgba(2, 26, 26, 0.8)',
+ 'surface-foreground': 'rgb(240, 240, 240)',
white: 'rgb(255, 255, 255)',
},
mode: 'dark',
diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts
index 3850e076..463e0c22 100644
--- a/src/shared/types/domain-types.ts
+++ b/src/shared/types/domain-types.ts
@@ -31,6 +31,7 @@ export enum LibraryItem {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
+ FOLDER = 'folder',
GENRE = 'genre',
PLAYLIST = 'playlist',
SONG = 'song',
@@ -255,6 +256,47 @@ export type EndpointDetails = {
server: ServerListItem;
};
+export type FolderItem = {
+ album?: string;
+ albumId?: string;
+ artist?: string;
+ artistId?: string;
+ coverArt?: string;
+ created?: string;
+ duration?: number;
+ genre?: string;
+ id: string;
+ imageUrl: null | string;
+ isDir: boolean;
+ itemType: LibraryItem.FOLDER;
+ name: string;
+ parent?: string;
+ path?: string;
+ playCount?: number;
+ serverId: string;
+ serverType: ServerType;
+ size?: number;
+ starred?: boolean;
+ suffix?: string;
+ title: string;
+ track?: number;
+ userRating?: number;
+ year?: number;
+};
+
+export type FolderListArgs = BaseEndpointArgs & { query: FolderListQuery };
+
+export interface FolderListQuery {
+ id: string;
+}
+
+export type FolderListResponse = {
+ id: string;
+ items: FolderItem[];
+ name: string;
+ parent?: string;
+};
+
export type GainInfo = {
album?: number;
track?: number;
@@ -1236,6 +1278,7 @@ export type ControllerEndpoint = {
getArtistList: (args: ArtistListArgs) => Promise;
getArtistListCount: (args: ArtistListCountArgs) => Promise;
getDownloadUrl: (args: DownloadArgs) => string;
+ getFolderList: (args: FolderListArgs) => Promise;
getGenreList: (args: GenreListArgs) => Promise;
getLyrics?: (args: LyricsArgs) => Promise;
getMusicFolderList: (args: MusicFolderListArgs) => Promise;
@@ -1314,6 +1357,7 @@ export type InternalControllerEndpoint = {
getArtistList: (args: ReplaceApiClientProps) => Promise;
getArtistListCount: (args: ReplaceApiClientProps) => Promise;
getDownloadUrl: (args: ReplaceApiClientProps) => string;
+ getFolderList: (args: ReplaceApiClientProps) => Promise;
getGenreList: (args: ReplaceApiClientProps) => Promise;
getLyrics?: (args: ReplaceApiClientProps) => Promise;
getMusicFolderList: (