From bf5e7bc77456575fc188272fa64c45bb6b713af7 Mon Sep 17 00:00:00 2001 From: Ante Budimir Date: Sun, 16 Nov 2025 15:54:47 +0200 Subject: [PATCH] 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 --- package.json | 2 +- src/i18n/locales/en.json | 11 +- src/renderer/app.tsx | 20 ++ .../components/card/poster-card.module.css | 5 +- .../grid-card/poster-card.module.css | 5 +- .../features/home/routes/home-route.tsx | 282 +++++++++++++++++- .../now-playing/components/play-queue.tsx | 138 ++++++--- .../components/full-screen-player.module.css | 5 +- .../player/components/full-screen-player.tsx | 6 +- .../components/player-button.module.css | 10 +- .../player/components/playerbar.module.css | 4 +- .../components/general/home-settings.tsx | 7 +- .../components/general/theme-settings.tsx | 42 +++ src/renderer/is-updated-dialog.tsx | 2 +- src/renderer/store/settings.store.ts | 28 +- src/renderer/themes/mantine-theme.tsx | 13 - src/shared/styles/global.css | 97 +++--- 17 files changed, 533 insertions(+), 144 deletions(-) diff --git a/package.json b/package.json index 4afff5b3..bcbcdf19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feishin", - "version": "0.23.0", + "version": "0.24.0", "description": "A modern self-hosted music player.", "keywords": [ "subsonic", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7eeca4fa..53f51518 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 3c8806c1..84ad45a4 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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 ( { }), ); - 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({ + 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 ; } - const carousels = { + const carousels: Record = { + [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 = () => { {carousel.title} carousel.query.refetch()} + onClick={() => + 'onRefresh' in carousel + ? carousel.onRefresh() + : carousel.query.refetch() + } variant="transparent" > diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 31387cf8..65ffd122 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -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) => { const tableRef = useRef(null); + const containerRef = useRef(null); const mergedRef = useMergedRef(ref, tableRef); const queue = useDefaultQueue(); - const { reorderQueue, setCurrentTrack } = useQueueControls(); + const { removeFromQueue, reorderQueue, setCurrentTrack } = useQueueControls(); const currentSong = useCurrentSong(); const previousSong = usePreviousSong(); const status = useCurrentStatus(); @@ -257,42 +267,98 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref< const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // Check if Delete or Backspace key was pressed + if (event.key === 'Delete' || event.key === 'Backspace') { + const { api } = tableRef?.current || {}; + if (!api) return; + + const selectedNodes = api.getSelectedNodes(); + if (!selectedNodes || selectedNodes.length === 0) return; + + const uniqueIds = selectedNodes.map((node) => node.data?.uniqueId).filter(Boolean); + if (!uniqueIds.length) return; + + const currentSongState = usePlayerStore.getState().current.song; + const playerData = removeFromQueue(uniqueIds as string[]); + const isCurrentSongRemoved = + currentSongState && uniqueIds.includes(currentSongState?.uniqueId); + + if (playbackType === PlaybackType.LOCAL) { + if (isCurrentSongRemoved) { + setQueue(playerData, false); + } else { + setQueueNext(playerData); + } + } + + api.redrawRows(); + + if (isCurrentSongRemoved) { + updateSong(playerData.current.song); + } + + event.preventDefault(); + } + }, + [playbackType, removeFromQueue], + ); + + // Add keyboard event listener to container + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('keydown', handleKeyDown); + + return () => { + container.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + return ( - - data.data.uniqueId} - onCellContextMenu={onCellContextMenu} - onCellDoubleClicked={handleDoubleClick} - onColumnMoved={handleColumnChange} - onColumnResized={debouncedColumnChange} - onDragStarted={handleDragStart} - onGridReady={handleGridReady} - onGridSizeChanged={handleGridSizeChange} - onRowDragEnd={handleDragEnd} - ref={mergedRef} - rowBuffer={50} - rowClassRules={rowClassRules} - rowData={songs} - rowDragEntireRow - rowDragMultiRow - rowHeight={tableConfig.rowHeight || 40} - suppressCellFocus={type === 'fullScreen'} - /> - +
+ + data.data.uniqueId} + onCellContextMenu={onCellContextMenu} + onCellDoubleClicked={handleDoubleClick} + onColumnMoved={handleColumnChange} + onColumnResized={debouncedColumnChange} + onDragStarted={handleDragStart} + onGridReady={handleGridReady} + onGridSizeChanged={handleGridSizeChange} + onRowDragEnd={handleDragEnd} + ref={mergedRef} + rowBuffer={50} + rowClassRules={rowClassRules} + rowData={songs} + rowDragEntireRow + rowDragMultiRow + rowHeight={tableConfig.rowHeight || 40} + suppressCellFocus={type === 'fullScreen'} + /> + +
); }); diff --git a/src/renderer/features/player/components/full-screen-player.module.css b/src/renderer/features/player/components/full-screen-player.module.css index 00f0ff2c..2d800b23 100644 --- a/src/renderer/features/player/components/full-screen-player.module.css +++ b/src/renderer/features/player/components/full-screen-player.module.css @@ -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; } diff --git a/src/renderer/features/player/components/full-screen-player.tsx b/src/renderer/features/player/components/full-screen-player.tsx index 75d0ab8c..85484dbd 100644 --- a/src/renderer/features/player/components/full-screen-player.tsx +++ b/src/renderer/features/player/components/full-screen-player.tsx @@ -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 = @@ -460,7 +462,7 @@ export const FullScreenPlayer = () => { } /> )} -
= [ - [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/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() {