import { AnimatePresence, motion } from 'motion/react'; import { Variants } from 'motion/react'; import { CSSProperties, memo, MouseEvent, ReactNode, useCallback, useEffect, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './mobile-fullscreen-player.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { Lyrics } from '/@/renderer/features/lyrics/lyrics'; import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue'; import { MobileFullscreenPlayerAlbumArt } from '/@/renderer/features/player/components/mobile-fullscreen-player-album-art'; import { MobileFullscreenPlayerBottomControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-bottom-controls'; import { MobileFullscreenPlayerControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-controls'; import { MobileFullscreenPlayerHeader } from '/@/renderer/features/player/components/mobile-fullscreen-player-header'; import { MobileFullscreenPlayerMetadata } from '/@/renderer/features/player/components/mobile-fullscreen-player-metadata'; import { MobileFullscreenPlayerProgress } from '/@/renderer/features/player/components/mobile-fullscreen-player-progress'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; import { useFastAverageColor } from '/@/renderer/hooks'; import { useCurrentServer, useFullScreenPlayerStore, useFullScreenPlayerStoreActions, usePlayerData, usePlayerSong, useSetFullScreenPlayerStore, } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Text } from '/@/shared/components/text/text'; import { LibraryItem, ServerType } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; const mainBackground = 'var(--theme-colors-background)'; const backgroundImageVariants: Variants = { closed: { opacity: 0, transition: { duration: 0.8, ease: 'linear', }, }, initial: { opacity: 0, }, open: (custom) => { const { isOpen } = custom; return { opacity: isOpen ? 1 : 0, transition: { duration: 0.4, ease: 'linear', }, }; }, }; interface BackgroundImageProps { dynamicBackground: boolean | undefined; dynamicIsImage: boolean | undefined; } const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundImageProps) => { const currentSong = usePlayerSong(); const { nextSong } = usePlayerData(); const [imageState, setImageState] = useState({ bottomImage: nextSong?.imageUrl ? nextSong.imageUrl.replace(/size=\d+/g, 'size=500') : undefined, current: 0, topImage: currentSong?.imageUrl ? currentSong.imageUrl.replace(/size=\d+/g, 'size=500') : undefined, }); const previousSongRef = useRef(currentSong?._uniqueId); const imageStateRef = useRef(imageState); useEffect(() => { imageStateRef.current = imageState; }, [imageState]); // Update images when song changes useEffect(() => { if (currentSong?._uniqueId === previousSongRef.current) { return; } const isTop = imageStateRef.current.current === 0; const currentImageUrl = currentSong?.imageUrl ? currentSong.imageUrl.replace(/size=\d+/g, 'size=500') : undefined; const nextImageUrl = nextSong?.imageUrl ? nextSong.imageUrl.replace(/size=\d+/g, 'size=500') : undefined; setImageState({ bottomImage: isTop ? currentImageUrl : nextImageUrl, current: isTop ? 1 : 0, topImage: isTop ? nextImageUrl : currentImageUrl, }); previousSongRef.current = currentSong?._uniqueId; }, [currentSong?._uniqueId, currentSong?.imageUrl, nextSong?._uniqueId, nextSong?.imageUrl]); if (!dynamicBackground || !dynamicIsImage) { return null; } const getBackgroundImageUrl = ( imageUrl: string | undefined, songId: string | undefined, albumId: string | undefined, ) => { if (!imageUrl || !songId || !albumId) { return imageUrl; } return imageUrl.replace(songId, albumId); }; // Determine which song IDs to use for keys and image URLs const topSongId = imageState.current === 0 ? currentSong?._uniqueId : nextSong?._uniqueId; const bottomSongId = imageState.current === 0 ? nextSong?._uniqueId : currentSong?._uniqueId; const topSong = imageState.current === 0 ? currentSong : nextSong; const bottomSong = imageState.current === 0 ? nextSong : currentSong; return ( {imageState.current === 0 && imageState.topImage && ( )} {imageState.current === 1 && imageState.bottomImage && ( )} ); }); BackgroundImage.displayName = 'BackgroundImage'; const overlayVariants: Variants = { closed: { opacity: 0, transition: { duration: 0, }, }, initial: { opacity: 1, }, open: { opacity: 1, transition: { duration: 0, }, }, }; interface BackgroundImageOverlayProps { dynamicBackground: boolean | undefined; dynamicImageBlur: number | undefined; } const BackgroundImageOverlay = memo( ({ dynamicBackground, dynamicImageBlur }: BackgroundImageOverlayProps) => { const currentSong = usePlayerSong(); const { nextSong } = usePlayerData(); const [overlayState, setOverlayState] = useState({ bottomSongId: nextSong?._uniqueId, current: 0, topSongId: currentSong?._uniqueId, }); const previousSongRef = useRef(currentSong?._uniqueId); const overlayStateRef = useRef(overlayState); useEffect(() => { overlayStateRef.current = overlayState; }, [overlayState]); // Update overlays when song changes useEffect(() => { if (currentSong?._uniqueId === previousSongRef.current) { return; } const isTop = overlayStateRef.current.current === 0; setOverlayState({ bottomSongId: isTop ? currentSong?._uniqueId : nextSong?._uniqueId, current: isTop ? 1 : 0, topSongId: isTop ? nextSong?._uniqueId : currentSong?._uniqueId, }); previousSongRef.current = currentSong?._uniqueId; }, [currentSong?._uniqueId, nextSong?._uniqueId]); if (!dynamicBackground) { return null; } return ( {overlayState.current === 0 && ( )} {overlayState.current === 1 && ( )} ); }, ); BackgroundImageOverlay.displayName = 'BackgroundImageOverlay'; interface MobilePlayerContainerProps { children: ReactNode; dynamicBackground: boolean | undefined; dynamicIsImage: boolean | undefined; } const MobilePlayerContainer = memo( ({ children, dynamicBackground, dynamicIsImage }: MobilePlayerContainerProps) => { const currentSong = usePlayerSong(); const { background } = useFastAverageColor({ algorithm: 'dominant', src: currentSong?.imageUrl, srcLoaded: true, }); let backgroundColor = mainBackground; if (dynamicBackground) { if (dynamicIsImage && background) { const rgbMatch = background.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { backgroundColor = `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, 0.3)`; } else { backgroundColor = background; } } else { backgroundColor = background || mainBackground; } } return ( {children} ); }, ); MobilePlayerContainer.displayName = 'MobilePlayerContainer'; const mobileContainerVariants: Variants = { closed: { transition: { duration: 0.5, ease: 'easeInOut', }, y: '100%', }, open: { transition: { duration: 0.5, ease: 'easeInOut', }, y: 0, }, }; export const MobileFullscreenPlayer = () => { const { t } = useTranslation(); const setFullScreenPlayerStore = useSetFullScreenPlayerStore(); const { setStore } = useFullScreenPlayerStoreActions(); const { activeTab, dynamicBackground, dynamicImageBlur, dynamicIsImage } = useFullScreenPlayerStore(); const currentSong = usePlayerSong(); const { currentSong: currentSongData } = usePlayerData(); const server = useCurrentServer(); const addToFavoritesMutation = useCreateFavorite({}); const removeFromFavoritesMutation = useDeleteFavorite({}); const updateRatingMutation = useSetRating({}); const [isPageHovered, setIsPageHovered] = useState(false); const handleToggleFullScreenPlayer = useCallback(() => { setFullScreenPlayerStore({ expanded: false }); }, [setFullScreenPlayerStore]); const handleToggleContextMenu = useCallback( (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!currentSong) { return; } ContextMenuController.call({ cmd: { items: [currentSong], type: LibraryItem.SONG }, event: e as unknown as MouseEvent, }); }, [currentSong], ); const handleToggleQueue = useCallback(() => { setStore({ activeTab: activeTab === 'queue' ? 'player' : 'queue' }); }, [activeTab, setStore]); const handleToggleFavorite = useCallback( (e: MouseEvent) => { e.stopPropagation(); const song = currentSongData; if (!song?.id) return; if (song.userFavorite) { removeFromFavoritesMutation.mutate({ apiClientProps: { serverId: song?._serverId || '' }, query: { id: [song.id], type: LibraryItem.SONG, }, }); } else { addToFavoritesMutation.mutate({ apiClientProps: { serverId: song?._serverId || '' }, query: { id: [song.id], type: LibraryItem.SONG, }, }); } }, [currentSongData, addToFavoritesMutation, removeFromFavoritesMutation], ); const handleToggleLyrics = useCallback(() => { setStore({ activeTab: activeTab === 'lyrics' ? 'player' : 'lyrics' }); }, [activeTab, setStore]); const handleUpdateRating = useCallback( (rating: number) => { if (!currentSong?.id) return; updateRatingMutation.mutate({ apiClientProps: { serverId: currentSong?._serverId || '' }, query: { id: [currentSong.id], rating, type: LibraryItem.SONG, }, }); }, [currentSong, updateRatingMutation], ); const isPlayerState = activeTab !== 'queue' && activeTab !== 'lyrics'; const isQueueState = activeTab === 'queue'; const isLyricsState = activeTab === 'lyrics'; const isSongDefined = Boolean(currentSong?.id); const showRating = isSongDefined && (server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC); return ( setIsPageHovered(true)} onMouseLeave={() => setIsPageHovered(false)} transition={{ duration: 0.3, ease: 'easeInOut' }} >
{t('page.fullscreenPlayer.lyrics', { postProcess: 'sentenceCase' })}
); };