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
This commit is contained in:
Ante Budimir 2025-11-16 15:54:47 +02:00
parent 228fc8e82b
commit bf5e7bc774
17 changed files with 533 additions and 144 deletions

View file

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

View file

@ -421,11 +421,14 @@
"title": "commands"
},
"home": {
"explore": "explore from your library",
"explore": "discovery",
"flashback": "flashback",
"mostPlayed": "most played",
"newlyAdded": "newly added releases",
"newlyAdded": "recently added",
"recentlyPlayed": "recently played",
"recentlyReleased": "recently released",
"starredAlbums": "starred albums",
"starredTracks": "starred tracks",
"title": "$t(common.home)"
},
"itemDetail": {
@ -600,6 +603,10 @@
"enableAutoTranslation": "enable auto translation",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"enableRemote": "enable remote control server",
"enableScanlineEffect_description": "enable the animated scanline visual effect across the application",
"enableScanlineEffect": "enable scanline effect",
"enableShimmerEffect_description": "enable the animated shimmer visual effect across the application",
"enableShimmerEffect": "enable shimmer effect",
"exitToTray_description": "exit the application to the system tray",
"exitToTray": "exit to tray",
"exportImportSettings_control_description": "export and import settings via JSON",

View file

@ -51,6 +51,8 @@ const utils = isElectron() ? window.api.utils : null;
export const App = () => {
const { mode, theme } = useAppTheme();
const language = useSettingsStore((store) => store.general.language);
const enableScanlineEffect = useSettingsStore((store) => store.general.enableScanlineEffect);
const enableShimmerEffect = useSettingsStore((store) => store.general.enableShimmerEffect);
const { content, enabled } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
@ -188,6 +190,24 @@ export const App = () => {
}
}, [language]);
useEffect(() => {
if (enableScanlineEffect) {
document.body.classList.add('enable-scanline');
document.documentElement.classList.add('enable-scanline');
} else {
document.body.classList.remove('enable-scanline');
document.documentElement.classList.remove('enable-scanline');
}
if (enableShimmerEffect) {
document.body.classList.add('enable-shimmer');
document.documentElement.classList.add('enable-shimmer');
} else {
document.body.classList.remove('enable-shimmer');
document.documentElement.classList.remove('enable-shimmer');
}
}, [enableScanlineEffect, enableShimmerEffect]);
return (
<MantineProvider forceColorScheme={mode} theme={theme}>
<Notifications

View file

@ -22,10 +22,10 @@
aspect-ratio: 1/1;
overflow: hidden;
background: var(--theme-card-default-bg);
border-radius: var(--theme-radius-md);
border: 2px solid var(--theme-orange-transparent-40);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: var(--theme-radius-md);
box-shadow: var(--theme-shadow-orange-glow-soft);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&::before {
position: absolute;
@ -47,7 +47,6 @@
var(--theme-shadow-orange-glow-medium),
0 0 5px var(--theme-orange-transparent-30),
0 0 5px var(--theme-orange-transparent-20);
transform: scale(1.03);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&::before {

View file

@ -25,11 +25,11 @@
align-items: center;
aspect-ratio: 1/1;
overflow: hidden;
border-radius: var(--theme-radius-md);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--theme-card-default-bg);
border: 2px solid var(--theme-orange-transparent-40);
border-radius: var(--theme-radius-md);
box-shadow: var(--theme-shadow-orange-glow-soft);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&::before {
position: absolute;
@ -51,7 +51,6 @@
var(--theme-shadow-orange-glow-medium),
0 0 5px var(--theme-orange-transparent-30),
0 0 5px var(--theme-orange-transparent-20);
transform: scale(1.03);
&::before {
opacity: 0.5;

View file

@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
import { FeatureCarousel } from '/@/renderer/components/feature-carousel/feature-carousel';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
@ -24,6 +25,8 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import {
Album,
AlbumListResponse,
AlbumListSort,
LibraryItem,
ServerType,
@ -33,7 +36,7 @@ import {
import { Platform } from '/@/shared/types/types';
const BASE_QUERY_ARGS = {
limit: 15,
limit: 30,
sortOrder: SortOrder.DESC,
startIndex: 0,
};
@ -173,20 +176,255 @@ const HomeRoute = () => {
}),
);
const isLoading =
(random.isLoading && queriesEnabled[HomeItem.RANDOM]) ||
(recentlyPlayed.isLoading && queriesEnabled[HomeItem.RECENTLY_PLAYED] && !isJellyfin) ||
(recentlyAdded.isLoading && queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
(recentlyReleased.isLoading && queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
(((isJellyfin && mostPlayedSongs.isLoading) ||
(!isJellyfin && mostPlayedAlbums.isLoading)) &&
const starredAlbums = useQuery(
albumQueries.list({
options: {
enabled: queriesEnabled[HomeItem.STARRED_ALBUMS],
staleTime: 1000 * 60 * 5,
},
query: {
...BASE_QUERY_ARGS,
favorite: true,
sortBy: AlbumListSort.FAVORITED,
sortOrder: SortOrder.DESC,
},
serverId: server?.id,
}),
);
const starredTracks = useQuery(
songsQueries.list(
{
options: {
enabled: queriesEnabled[HomeItem.STARRED_TRACKS],
staleTime: 1000 * 60 * 5,
},
query: {
...BASE_QUERY_ARGS,
favorite: true,
sortBy: SongListSort.FAVORITED,
sortOrder: SortOrder.DESC,
},
serverId: server?.id,
},
300,
),
);
// Flashback: Get a random decade from the past
// Pre-compute which decades have albums to avoid empty queries
const [flashbackSeed, setFlashbackSeed] = useState(0);
const [hasFlashbackLoaded, setHasFlashbackLoaded] = useState(false);
// Get all available decades with albums
const availableDecades = useMemo(() => {
const currentYear = new Date().getFullYear();
const currentDecade = Math.floor(currentYear / 10) * 10;
const minDecade = 1920;
const decades: Array<{ decade: number; maxYear: number; minYear: number }> = [];
for (let decade = minDecade; decade <= currentDecade; decade += 10) {
decades.push({
decade,
maxYear: decade + 9,
minYear: decade,
});
}
return decades;
}, []);
// Get count for each decade
const decadeQueries = useQuery({
enabled: queriesEnabled[HomeItem.FLASHBACK] && !!server?.id,
queryFn: async () => {
if (!server?.id) return [];
const promises = availableDecades.map(async ({ decade, maxYear, minYear }) => {
try {
const count = await api.controller.getAlbumListCount({
apiClientProps: { serverId: server.id },
query: {
maxYear,
minYear,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
},
});
return { count, decade, hasAlbums: count > 0 };
} catch (error) {
console.error(`Error checking decade ${decade}s:`, error);
return { count: 0, decade, hasAlbums: false };
}
});
const results = await Promise.all(promises);
return results.filter((r) => r.hasAlbums);
},
queryKey: ['flashback-decades', server?.id],
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
});
// Get a random decade from available ones
const { flashbackDecade, flashbackMaxYear, flashbackMinYear } = useMemo(() => {
if (decadeQueries.data && decadeQueries.data.length > 0) {
const randomIndex = Math.floor(Math.random() * decadeQueries.data.length);
const selectedDecade = decadeQueries.data[randomIndex];
return {
flashbackDecade: selectedDecade.decade,
flashbackMaxYear: selectedDecade.decade + 9,
flashbackMinYear: selectedDecade.decade,
};
}
// Fallback to current decade if no data yet
const currentYear = new Date().getFullYear();
const currentDecade = Math.floor(currentYear / 10) * 10;
return {
flashbackDecade: currentDecade,
flashbackMaxYear: currentDecade + 9,
flashbackMinYear: currentDecade,
};
// flashbackSeed is intentionally included to force re-computation on refresh
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decadeQueries.data, flashbackSeed]);
const flashback = useQuery<AlbumListResponse>({
enabled:
queriesEnabled[HomeItem.FLASHBACK] &&
decadeQueries.data &&
decadeQueries.data.length > 0 &&
!!server?.id,
gcTime: 1000 * 60 * 5,
placeholderData: (): AlbumListResponse => {
// Create placeholder data with correct structure but empty items
// This prevents UI jumping while avoiding cross-decade cache contamination
return {
items: Array(BASE_QUERY_ARGS.limit)
.fill(null)
.map((_, index) => ({
albumArtist: '',
albumArtists: [], // Required for card rows
artists: [],
backdropImageUrl: null,
comment: null,
createdAt: '',
duration: null,
explicitStatus: null,
genres: [],
id: `placeholder-${flashbackSeed}-${index}`,
imagePlaceholderUrl: null,
imageUrl: null,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
mbzId: null,
name: '',
originalDate: null,
participants: null,
playCount: null,
recordLabels: [],
releaseDate: null,
releaseTypes: [],
releaseYear: null,
serverId: server?.id || '',
serverType: server?.type || ServerType.JELLYFIN,
size: null,
songCount: null,
tags: null,
uniqueId: `placeholder-${flashbackSeed}-${index}`,
updatedAt: '',
userFavorite: false,
userRating: null,
version: null,
})) as Album[],
startIndex: 0,
totalRecordCount: 0,
};
},
queryFn: ({ signal }) => {
const result = api.controller.getAlbumList({
apiClientProps: { serverId: server?.id, signal },
query: {
...BASE_QUERY_ARGS,
maxYear: flashbackMaxYear,
minYear: flashbackMinYear,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
},
});
result.then((data) => {
console.log(
`[Flashback] Fetched ${data.items?.length || 0} albums for ${flashbackDecade}s (seed: ${flashbackSeed})`,
);
});
return result;
},
queryKey: [
'albums',
'list',
server?.id,
{
...BASE_QUERY_ARGS,
_flashbackSeed: flashbackSeed, // Force unique cache key
maxYear: flashbackMaxYear,
minYear: flashbackMinYear,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
},
],
staleTime: 0, // Force fresh data every time to prevent cross-decade caching
});
// Track when Flashback has successfully loaded at least once
useEffect(() => {
if (flashback.data && !hasFlashbackLoaded) {
setHasFlashbackLoaded(true);
}
}, [flashback.data, hasFlashbackLoaded]);
// Only show flashback if we have decades with albums
const shouldShowFlashback = decadeQueries.data && decadeQueries.data.length > 0;
// Only show full-page spinner on initial load, not on refetch
const isInitialLoading =
(random.isLoading && !random.data && queriesEnabled[HomeItem.RANDOM]) ||
(recentlyPlayed.isLoading &&
!recentlyPlayed.data &&
queriesEnabled[HomeItem.RECENTLY_PLAYED] &&
!isJellyfin) ||
(recentlyAdded.isLoading &&
!recentlyAdded.data &&
queriesEnabled[HomeItem.RECENTLY_ADDED]) ||
(recentlyReleased.isLoading &&
!recentlyReleased.data &&
queriesEnabled[HomeItem.RECENTLY_RELEASED]) ||
(starredAlbums.isLoading &&
!starredAlbums.data &&
queriesEnabled[HomeItem.STARRED_ALBUMS]) ||
(starredTracks.isLoading &&
!starredTracks.data &&
queriesEnabled[HomeItem.STARRED_TRACKS]) ||
(flashback.isLoading && !hasFlashbackLoaded && queriesEnabled[HomeItem.FLASHBACK]) ||
(((isJellyfin && mostPlayedSongs.isLoading && !mostPlayedSongs.data) ||
(!isJellyfin && mostPlayedAlbums.isLoading && !mostPlayedAlbums.data)) &&
queriesEnabled[HomeItem.MOST_PLAYED]);
if (isLoading) {
if (isInitialLoading) {
return <Spinner container />;
}
const carousels = {
const carousels: Record<HomeItem, any> = {
[HomeItem.FLASHBACK]: {
data: flashback?.data?.items,
itemType: LibraryItem.ALBUM,
onRefresh: () => {
// Incrementing seed changes query key, which automatically triggers a new fetch
setFlashbackSeed((prev) => prev + 1);
},
query: flashback,
title: `${t('page.home.flashback', { postProcess: 'sentenceCase' })} - ${flashbackDecade}s`,
},
[HomeItem.MOST_PLAYED]: {
data: isJellyfin ? mostPlayedSongs?.data?.items : mostPlayedAlbums?.data?.items,
itemType: isJellyfin ? LibraryItem.SONG : LibraryItem.ALBUM,
@ -217,6 +455,18 @@ const HomeRoute = () => {
query: recentlyReleased,
title: t('page.home.recentlyReleased', { postProcess: 'sentenceCase' }),
},
[HomeItem.STARRED_ALBUMS]: {
data: starredAlbums?.data?.items,
itemType: LibraryItem.ALBUM,
query: starredAlbums,
title: t('page.home.starredAlbums', { postProcess: 'sentenceCase' }),
},
[HomeItem.STARRED_TRACKS]: {
data: starredTracks?.data?.items,
itemType: LibraryItem.SONG,
query: starredTracks,
title: t('page.home.starredTracks', { postProcess: 'sentenceCase' }),
},
};
const sortedCarousel = homeItems
@ -227,6 +477,10 @@ const HomeRoute = () => {
if (isJellyfin && item.id === HomeItem.RECENTLY_PLAYED) {
return false;
}
// Don't show flashback carousel if it has no data
if (item.id === HomeItem.FLASHBACK && !shouldShowFlashback) {
return false;
}
return true;
})
@ -310,7 +564,11 @@ const HomeRoute = () => {
<Group>
<TextTitle order={3}>{carousel.title}</TextTitle>
<ActionIcon
onClick={() => carousel.query.refetch()}
onClick={() =>
'onRefresh' in carousel
? carousel.onRefresh()
: carousel.query.refetch()
}
variant="transparent"
>
<Icon icon="refresh" />

View file

@ -11,7 +11,15 @@ import { useMergedRef } from '@mantine/hooks';
import '@ag-grid-community/styles/ag-theme-alpine.css';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
@ -28,6 +36,7 @@ import {
useCurrentStatus,
useDefaultQueue,
usePlayerControls,
usePlayerStore,
usePreviousSong,
useQueueControls,
useVolume,
@ -53,9 +62,10 @@ type QueueProps = {
export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<any>) => {
const tableRef = useRef<AgGridReactType | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const queue = useDefaultQueue();
const { reorderQueue, setCurrentTrack } = useQueueControls();
const { removeFromQueue, reorderQueue, setCurrentTrack } = useQueueControls();
const currentSong = useCurrentSong();
const previousSong = usePreviousSong();
const status = useCurrentStatus();
@ -257,8 +267,63 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check if Delete or Backspace key was pressed
if (event.key === 'Delete' || event.key === 'Backspace') {
const { api } = tableRef?.current || {};
if (!api) return;
const selectedNodes = api.getSelectedNodes();
if (!selectedNodes || selectedNodes.length === 0) return;
const uniqueIds = selectedNodes.map((node) => node.data?.uniqueId).filter(Boolean);
if (!uniqueIds.length) return;
const currentSongState = usePlayerStore.getState().current.song;
const playerData = removeFromQueue(uniqueIds as string[]);
const isCurrentSongRemoved =
currentSongState && uniqueIds.includes(currentSongState?.uniqueId);
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
setQueue(playerData, false);
} else {
setQueueNext(playerData);
}
}
api.redrawRows();
if (isCurrentSongRemoved) {
updateSong(playerData.current.song);
}
event.preventDefault();
}
},
[playbackType, removeFromQueue],
);
// Add keyboard event listener to container
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div
ref={containerRef}
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}
tabIndex={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
alwaysShowHorizontalScroll
@ -293,6 +358,7 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
suppressCellFocus={type === 'fullScreen'}
/>
</VirtualGridAutoSizerContainer>
</div>
</ErrorBoundary>
);
});

View file

@ -46,21 +46,22 @@
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(
0deg,
transparent 0%,
var(--album-color, rgba(0, 255, 255, 0.15)) 50%,
var(--album-color, rgb(0 255 255 / 15%)) 50%,
transparent 100%
);
background-size: 100% 200%;
animation: scanline 6s linear infinite;
pointer-events: none;
}
@keyframes scanline {
0% {
background-position: 0 -100vh;
}
100% {
background-position: 0 100vh;
}

View file

@ -429,7 +429,9 @@ export const FullScreenPlayer = () => {
});
// 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 scanlineColor = background
? background.replace('rgb', 'rgba').replace(')', ', 0.15)')
: 'rgba(0, 255, 255, 0.15)';
const imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
const backgroundImage =

View file

@ -24,7 +24,6 @@
}
&:hover {
transform: scale(1.1);
filter: drop-shadow(0 0 8px var(--theme-orange-transparent-40));
}
@ -39,20 +38,23 @@
.player-button.active {
svg {
fill: var(--theme-orange-base);
filter: drop-shadow(0 0 6px var(--theme-orange-transparent-50));
fill: var(--theme-orange-base);
}
}
.main {
background: linear-gradient(135deg, var(--theme-orange-base), var(--theme-orange-medium)) !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);
transform: scale(1.15);
}
svg {

View file

@ -1,12 +1,12 @@
.container {
width: 100vw;
height: 100%;
border-top: 2px solid var(--theme-orange-transparent-40);
background: linear-gradient(
180deg,
var(--theme-colors-background) 0%,
rgba(2, 26, 26, 0.95) 100%
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);
}

View file

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

View file

@ -161,6 +161,48 @@ export const ThemeSettings = () => {
}),
title: t('setting.accentColor', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
checked={settings.enableShimmerEffect}
onChange={(e) => {
setSettings({
general: {
...settings,
enableShimmerEffect: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.enableShimmerEffect', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.enableShimmerEffect', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
checked={settings.enableScanlineEffect}
onChange={(e) => {
setSettings({
general: {
...settings,
enableScanlineEffect: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.enableScanlineEffect', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.enableScanlineEffect', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection options={themeOptions} />;

View file

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

View file

@ -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([
@ -177,6 +180,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 +393,14 @@ export enum GenreTarget {
}
export enum HomeItem {
FLASHBACK = 'flashback',
MOST_PLAYED = 'mostPlayed',
RANDOM = 'random',
RECENTLY_ADDED = 'recentlyAdded',
RECENTLY_PLAYED = 'recentlyPlayed',
RECENTLY_RELEASED = 'recentlyReleased',
STARRED_ALBUMS = 'starredAlbums',
STARRED_TRACKS = 'starredTracks',
}
export type DataTableProps = z.infer<typeof DataTablePropsSchema>;
@ -495,10 +503,16 @@ export const sidebarItems: SidebarItemType[] = [
},
];
const homeItems = Object.values(HomeItem).map((item) => ({
disabled: false,
id: item,
}));
const homeItems: SortableItem<HomeItem>[] = [
{ disabled: false, id: HomeItem.FLASHBACK },
{ disabled: false, id: HomeItem.RECENTLY_PLAYED },
{ disabled: false, id: HomeItem.RANDOM },
{ disabled: false, id: HomeItem.RECENTLY_ADDED },
{ disabled: false, id: HomeItem.STARRED_ALBUMS },
{ disabled: false, id: HomeItem.STARRED_TRACKS },
{ disabled: false, id: HomeItem.MOST_PLAYED },
{ disabled: true, id: HomeItem.RECENTLY_RELEASED },
];
const artistItems = Object.values(ArtistItem).map((item) => ({
disabled: false,
@ -534,7 +548,7 @@ const initialState: SettingsState = {
type: FontType.SYSTEM,
},
general: {
accent: 'rgb(214, 46, 83)',
accent: 'rgb(255, 142, 83)',
albumArtRes: undefined,
albumBackground: true,
albumBackgroundBlur: 50,
@ -544,6 +558,8 @@ const initialState: SettingsState = {
buttonSize: 25,
disabledContextMenu: {},
doubleClickQueueAll: false,
enableScanlineEffect: true,
enableShimmerEffect: true,
externalLinks: true,
followSystemTheme: false,
genreTarget: GenreTarget.ALBUM,
@ -558,7 +574,7 @@ const initialState: SettingsState = {
playButtonBehavior: Play.NOW,
playerbarOpenDrawer: false,
resume: true,
showQueueDrawerButton: true,
showQueueDrawerButton: false,
sidebarCollapsedNavigation: true,
sidebarCollapseShared: false,
sidebarItems,

View file

@ -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',

View file

@ -33,46 +33,48 @@ html {
background: var(--theme-colors-background);
}
body::before,
html::before {
content: '';
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%,
rgba(0, 183, 255, 0.1) 45%,
rgba(0, 183, 255, 0.2) 50%,
rgba(0, 183, 255, 0.1) 55%,
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;
pointer-events: none;
z-index: 1;
}
body::after,
html::after {
content: '';
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%,
rgba(0, 255, 255, 0.03) 50%,
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;
pointer-events: none;
z-index: 2;
}
input,
@ -100,9 +102,9 @@ img {
}
#app {
height: inherit;
position: relative;
z-index: 10;
height: inherit;
}
::-webkit-scrollbar {
@ -164,27 +166,13 @@ button {
}
}
@keyframes hologramGlow {
0%,
100% {
box-shadow:
0 0 10px rgba(0, 183, 255, 0.2),
0 0 20px rgba(0, 183, 255, 0.1),
0 0 40px rgba(0, 255, 255, 0.05);
}
50% {
box-shadow:
0 0 20px rgba(0, 183, 255, 0.4),
0 0 40px rgba(0, 183, 255, 0.2),
0 0 80px rgba(0, 255, 255, 0.1);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
@ -192,8 +180,9 @@ button {
@keyframes shimmer {
0% {
background-position: -100% 0;
background-position: 0% 0;
}
100% {
background-position: 200% 0;
}
@ -203,6 +192,7 @@ button {
0% {
background-position: 0 -100vh;
}
100% {
background-position: 0 100vh;
}
@ -312,30 +302,27 @@ button {
:root {
--theme-background-noise: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=');
--theme-fullscreen-player-text-shadow: black 0px 0px 10px;
/* Intro-inspired color palette */
--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: rgba(255, 142, 83, 0.7);
--theme-orange-transparent-40: rgba(255, 142, 83, 0.4);
--theme-orange-transparent-30: rgba(255, 142, 83, 0.3);
--theme-orange-transparent-20: rgba(255, 142, 83, 0.2);
--theme-orange-transparent-15: rgba(255, 142, 83, 0.15);
--theme-orange-transparent-10: rgba(255, 142, 83, 0.1);
--theme-cyan-primary: rgb(0, 183, 255);
--theme-cyan-secondary: rgb(0, 255, 255);
--theme-cyan-transparent-80: rgba(0, 183, 255, 0.8);
--theme-cyan-transparent-70: rgba(0, 183, 255, 0.7);
--theme-cyan-transparent-60: rgba(0, 183, 255, 0.6);
--theme-cyan-transparent-50: rgba(0, 183, 255, 0.5);
--theme-cyan-transparent-40: rgba(0, 183, 255, 0.4);
--theme-cyan-transparent-30: rgba(0, 183, 255, 0.3);
--theme-cyan-transparent-20: rgba(0, 183, 255, 0.2);
--theme-cyan-transparent-10: rgba(0, 183, 255, 0.1);
--theme-cyan-transparent-05: rgba(0, 183, 255, 0.05);
--theme-cyan-transparent-03: rgba(0, 183, 255, 0.03);
--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(