mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
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:
parent
228fc8e82b
commit
bf5e7bc774
17 changed files with 533 additions and 144 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "feishin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.24.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue