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,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,42 +267,98 @@ export const PlayQueue = forwardRef(({ searchTerm, type }: QueueProps, ref: Ref<
const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, QUEUE_CONTEXT_MENU_ITEMS);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check if Delete or Backspace key was pressed
if (event.key === 'Delete' || event.key === 'Backspace') {
const { api } = tableRef?.current || {};
if (!api) return;
const selectedNodes = api.getSelectedNodes();
if (!selectedNodes || selectedNodes.length === 0) return;
const uniqueIds = selectedNodes.map((node) => node.data?.uniqueId).filter(Boolean);
if (!uniqueIds.length) return;
const currentSongState = usePlayerStore.getState().current.song;
const playerData = removeFromQueue(uniqueIds as string[]);
const isCurrentSongRemoved =
currentSongState && uniqueIds.includes(currentSongState?.uniqueId);
if (playbackType === PlaybackType.LOCAL) {
if (isCurrentSongRemoved) {
setQueue(playerData, false);
} else {
setQueueNext(playerData);
}
}
api.redrawRows();
if (isCurrentSongRemoved) {
updateSong(playerData.current.song);
}
event.preventDefault();
}
},
[playbackType, removeFromQueue],
);
// Add keyboard event listener to container
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<VirtualGridAutoSizerContainer>
<VirtualTable
alwaysShowHorizontalScroll
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
context={{
currentSong,
handleDoubleClick,
isFocused,
isQueue: true,
itemType: LibraryItem.SONG,
onCellContextMenu,
status,
}}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
onCellContextMenu={onCellContextMenu}
onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
ref={mergedRef}
rowBuffer={50}
rowClassRules={rowClassRules}
rowData={songs}
rowDragEntireRow
rowDragMultiRow
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
/>
</VirtualGridAutoSizerContainer>
<div
ref={containerRef}
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}
tabIndex={0}
>
<VirtualGridAutoSizerContainer>
<VirtualTable
alwaysShowHorizontalScroll
autoFitColumns={tableConfig.autoFit}
columnDefs={columnDefs}
context={{
currentSong,
handleDoubleClick,
isFocused,
isQueue: true,
itemType: LibraryItem.SONG,
onCellContextMenu,
status,
}}
deselectOnClickOutside={type === 'fullScreen'}
getRowId={(data) => data.data.uniqueId}
onCellContextMenu={onCellContextMenu}
onCellDoubleClicked={handleDoubleClick}
onColumnMoved={handleColumnChange}
onColumnResized={debouncedColumnChange}
onDragStarted={handleDragStart}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChange}
onRowDragEnd={handleDragEnd}
ref={mergedRef}
rowBuffer={50}
rowClassRules={rowClassRules}
rowData={songs}
rowDragEntireRow
rowDragMultiRow
rowHeight={tableConfig.rowHeight || 40}
suppressCellFocus={type === 'fullScreen'}
/>
</VirtualGridAutoSizerContainer>
</div>
</ErrorBoundary>
);
});

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 =
@ -460,7 +462,7 @@ export const FullScreenPlayer = () => {
}
/>
)}
<div
<div
className={styles.scanlineOverlay}
style={
{

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