import { ArrowLeftIcon, BookmarkFillIcon, ChevronDownIcon, HeartFillIcon } from '@primer/octicons-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useConnectivity } from '../hooks/useConnectivity' import { Link, useLocation, useNavigate, useNavigationType } from 'react-router-dom' import { useFilterContext } from '../context/FilterContext/FilterContext' import { FilterContextProvider } from '../context/FilterContext/FilterContextProvider' import { HeaderSearchBar } from './HeaderSearchBar' import { AudioIdentifier } from './AudioIdentifier' import { DownloadTracker } from './DownloadTracker' import { useHistoryContext } from '../context/HistoryContext/HistoryContext' import { usePageTitle } from '../context/PageTitleContext/PageTitleContext' import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' import { useSidenavContext } from '../context/SidenavContext/SidenavContext' import { useDuration } from '../hooks/useDuration' import { getPageTitle } from '../utils/titleUtils' import { JellyImg } from './JellyImg' import { Squircle } from './Squircle' import { AlbumIcon, ArtistsIcon, ExpandIcon, PlaylistIcon, SortingIcon, TrackIcon, TracksIcon } from './SvgIcons' export const Main = (props: Parameters[0]) => { return ( ) } export const MainContent = ({ content: Content, filterType, }: { content: React.ComponentType filterType?: 'mediaItems' | 'favorites' | 'kind' | 'mediaItemsPlaylist' }) => { const playback = usePlaybackContext() const { pageTitle } = usePageTitle() const historyCtx = useHistoryContext() const location = useLocation() const navigate = useNavigate() const { toggleSidenav } = useSidenavContext() const { filter, setFilter } = useFilterContext() const { currentTrack } = usePlaybackContext() const navigationType = useNavigationType() useEffect(() => { // Only force scroll to top on forward navigations, not when going back if (navigationType === 'PUSH' || navigationType === 'REPLACE') { window.scrollTo({ top: 0, behavior: 'instant' }) } }, [location.pathname, navigationType]) // Show brief offline/online toasts; no intrusive banners useConnectivity({ showToasts: true }) const previousPage = useCallback(() => { if (historyCtx && typeof historyCtx.goBack === 'function') { historyCtx.goBack() } else { navigate(-1) } }, [historyCtx, navigate]) const renderHeader = () => { return (
{getPageTitle(pageTitle, location)}
{location.pathname.startsWith('/album/') && pageTitle && (
)} {pageTitle && (location.pathname.match(/^\/artist\/[^/]+\/tracks$/) ? (
) : location.pathname.startsWith('/artist/') ? (
) : null)} {location.pathname.startsWith('/genre/') && pageTitle && (
)} {location.pathname.startsWith('/playlist/') && pageTitle && (
)}
{/* Search Bar */}
{/* Audio Identification Button */}
{/* Enhanced Download Tracker */}
{(filterType === 'mediaItems' || filterType === 'mediaItemsPlaylist') && (
{filter.sort !== 'Inherit' && (
{ setFilter(c => ({ ...c, order: c.order === 'Ascending' ? 'Descending' : 'Ascending', })) }} title={filter.order === 'Ascending' ? 'Ascending' : 'Descending'} >
)}
)} {(filterType === 'favorites' || filterType === 'kind') && ( <>
{filter.kind === 'Tracks' && ( )} {filter.kind === 'Albums' && ( )} {filter.kind === 'Artists' && ( )}
{filterType === 'favorites' && (
{ setFilter(c => ({ ...c, order: c.order === 'Ascending' ? 'Descending' : 'Ascending', })) }} title={filter.order === 'Ascending' ? 'Ascending' : 'Descending'} >
)} )}
) } const memoContent = useMemo(() => { return (
) }, [Content]) const [launchingNowPlaying, setLaunchingNowPlaying] = useState(false) const [showTapRipple, setShowTapRipple] = useState(false) const handleOpenNowPlaying = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement // Do not hijack clicks on existing interactive elements/links/controls if ( target.closest('a, button, [role="button"], input, select, textarea, .controls') ) { return } if (launchingNowPlaying) return setLaunchingNowPlaying(true) setShowTapRipple(true) window.setTimeout(() => setShowTapRipple(false), 500) // Navigate after the slide-up animation window.setTimeout(() => { // Ensure current list scroll is saved before navigation window.dispatchEvent(new Event('app:save-scroll')) navigate('/nowplaying') }, 240) }, [navigate, launchingNowPlaying]) const memoFooter = useMemo(() => { return (
{/* Slide-up launch animation overlay */}
{/* Subtle center ripple cue */}
{ // Save scroll if user taps the artwork link directly window.dispatchEvent(new Event('app:save-scroll')) }} > {currentTrack && ( )} {!currentTrack && (
)}
{playback.currentTrack?.Name ? ( {playback.currentTrack.Name} ) : ( 'No Track Played' )}
{playback.currentTrack?.UserData?.IsFavorite && ( )}
{playback.currentTrack && playback.currentTrack.ArtistItems && playback.currentTrack.ArtistItems.length > 0 ? (() => { const artistItems = playback.currentTrack.ArtistItems return artistItems.map((artist: any, index: number) => ( {artist.Name} {index < artistItems.length - 1 && ', '} )) })() : playback.currentTrack?.Artists?.join(', ') || playback.currentTrack?.AlbumArtist || 'No Artist'}
{playback.currentTrack?.Album ? ( {playback.currentTrack.Album} ) : (
No Album
)}
) }, [ currentTrack, playback.isPlaying, playback.nextTrack, playback.previousTrack, playback.repeat, playback.shuffle, playback.togglePlayPause, playback.toggleRepeat, playback.toggleShuffle, handleOpenNowPlaying, launchingNowPlaying, ]) return (
{renderHeader()} {memoContent} {memoFooter} {/* Mobile Bottom Navigation */}
🏠 Home 🎵 Tracks ⬇️ Downloads 🌐 Search 📋 Playlists
) } export const Progressbar = () => { const playback = usePlaybackContext() const audio = playback.audioRef.current as HTMLAudioElement | undefined const progressRef = useRef(null) const trackRef = useRef(null) const bufferRef = useRef(false) const calcDuration = useCallback(() => { return audio?.duration || 0 }, [audio]) const calcProgress = useCallback(() => { if (!audio) return 0 const d = calcDuration() const t = audio.currentTime || 0 if (d > 0) { return (t / d) * 100 } return 0 }, [audio, calcDuration]) const restoreProgress = useCallback(() => { if (bufferRef.current) { return } progressRef.current?.style.setProperty('--transition-duration', `0s`) progressRef.current?.style.setProperty('--progress-width', `${calcProgress()}%`) if (playback.isPlaying && audio?.currentTime) { void progressRef.current?.offsetWidth // Trigger reflow progressRef.current?.style.setProperty('--transition-duration', `${calcDuration() - audio.currentTime}s`) progressRef.current?.style.setProperty('--progress-width', `100%`) } }, [audio?.currentTime, calcDuration, calcProgress, playback.isPlaying]) const calcBuffered = useCallback(() => { if (!audio) return 0 if (audio.buffered.length > 0) { const end = audio.buffered.end(audio.buffered.length - 1) return (end / (audio.duration || 1)) * 100 } return 0 }, [audio]) useEffect(() => { if (!audio) return progressRef.current?.style.setProperty('--progress-width', `${calcProgress()}%`) progressRef.current?.style.setProperty('--transition-duration', `${calcDuration()}s`) }, [audio, calcDuration, calcProgress]) useEffect(() => { if (!audio) return if (playback.isPlaying) { restoreProgress() } else { progressRef.current?.style.setProperty('--progress-width', `${calcProgress()}%`) progressRef.current?.style.setProperty('--transition-duration', `0s`) } }, [audio, audio?.src, calcProgress, playback.isPlaying, restoreProgress]) useEffect(() => { if (!audio) return const handleTimeUpdate = () => { if (!audio.duration) { return } restoreProgress() } const handleBuffered = () => { progressRef.current?.style.setProperty('--buffered-width', `${calcBuffered()}%`) } const handleEmptied = () => { progressRef.current?.style.setProperty('--buffered-width', `0%`) progressRef.current?.style.setProperty('--progress-width', `0%`) progressRef.current?.style.setProperty('--transition-duration', `0s`) void progressRef.current?.offsetWidth // Trigger reflow } const handleChange = () => { progressRef.current?.style.setProperty('--progress-width', `${calcProgress()}%`) progressRef.current?.style.setProperty('--transition-duration', `${calcDuration()}s`) } const handleWaiting = () => { bufferRef.current = true progressRef.current?.classList.add('loading') progressRef.current?.style.setProperty('--transition-duration', `0s`) progressRef.current?.style.setProperty('--progress-width', `${calcProgress()}%`) } const handlePlaying = () => { bufferRef.current = false progressRef.current?.classList.remove('loading') restoreProgress() } audio.addEventListener('timeupdate', handleTimeUpdate) audio.addEventListener('emptied', handleEmptied) audio.addEventListener('progress', handleBuffered) audio.addEventListener('loadedmetadata', handleChange) audio.addEventListener('waiting', handleWaiting) audio.addEventListener('playing', handlePlaying) return () => { audio.removeEventListener('timeupdate', handleTimeUpdate) audio.removeEventListener('emptied', handleEmptied) audio.removeEventListener('progress', handleBuffered) audio.removeEventListener('loadedmetadata', handleChange) audio.removeEventListener('waiting', handleWaiting) audio.removeEventListener('playing', handlePlaying) } }, [audio, calcBuffered, calcDuration, calcProgress, restoreProgress]) // better progress bar support iOS const handleTouchMove = (e: React.TouchEvent) => { e.preventDefault() if (!audio || !progressRef.current || !trackRef.current) return const rect = trackRef.current.getBoundingClientRect() const percent = Math.min(Math.max((e.touches[0].clientX - rect.left) / rect.width, 0), 1) const newTime = percent * calcDuration() audio.currentTime = newTime progressRef.current.value = newTime.toString() restoreProgress() } return (
{ if (!audio) return const t = parseFloat(e.currentTarget.value) audio.currentTime = t restoreProgress() }} />
) } const Duration = memo(() => { const playback = usePlaybackContext() const duration = useDuration() return (
{playback.formatTime(duration.progress)}
/
{playback.formatTime(duration.duration)}
) })