import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'; import { BookmarkFillIcon, GearIcon, LightBulbIcon } from '@primer/octicons-react'; import { ChangeEvent, useEffect, useRef, useState, WheelEvent } from 'react' import { NavLink } from 'react-router-dom' import { MediaItem } from '../api/jellyfin' import '../App.css' import { useDownloadContext } from '../context/DownloadContext/DownloadContext' import { useDropdownContext } from '../context/DropdownContext/DropdownContext' import { buildUrlWithSavedFilters } from '../context/FilterContext/FilterContext' import { useJellyfinContext } from '../context/JellyfinContext/JellyfinContext' import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' import { useScrollContext } from '../context/ScrollContext/ScrollContext' import { useSidenavContext } from '../context/SidenavContext/SidenavContext' import { useJellyfinPlaylistsList } from '../hooks/Jellyfin/useJellyfinPlaylistsList' import { downloadedPlaylistManager, DownloadedPlaylist } from '../utils/downloadedPlaylistManager' import { InlineLoader } from './InlineLoader' import './Sidenav.css' import { AlbumIcon, ArtistsIcon, DownloadingIcon, LyricsIcon, PlaylistIcon, PlaystateAnimationSearch, SearchClearIcon, SearchIcon, TrackIcon, } from './SvgIcons' export const Sidenav = (props: { username: string }) => { const api = useJellyfinContext() const playback = usePlaybackContext() const searchInputRef = useRef(null) const { showSidenav, closeSidenav } = useSidenavContext() const { playlists, loading, error } = useJellyfinPlaylistsList() const { disabled, setDisabled } = useScrollContext() const [searchQuery, setSearchQuery] = useState(new URLSearchParams(location.search).get('search') || '') const [searchResults, setSearchResults] = useState([]) const [searchLoading, setSearchLoading] = useState(false) const [searchError, setSearchError] = useState(null) const [searchAttempted, setSearchAttempted] = useState(false) const dropdown = useDropdownContext() const { storageStats } = useDownloadContext() // Downloaded playlists state const [downloadedPlaylists, setDownloadedPlaylists] = useState([]) // Load downloaded playlists on mount and when storage changes useEffect(() => { const loadDownloadedPlaylists = () => { const downloaded = downloadedPlaylistManager.getDownloadedPlaylists() setDownloadedPlaylists(downloaded) } loadDownloadedPlaylists() // Listen for storage changes const handleStorageChange = () => loadDownloadedPlaylists() window.addEventListener('storage', handleStorageChange) return () => window.removeEventListener('storage', handleStorageChange) }, []) const navigationItems = [ { to: '/', icon: , label: 'Home' }, { to: '/discover', icon: , label: 'Discover' }, { to: '/tracks', icon: , label: 'Tracks' }, { to: '/albums', icon: , label: 'Albums' }, { to: '/artists', icon: , label: 'Artists' }, { to: '/genres', icon: , label: 'Genres' }, { to: '/favorites', icon: , label: 'Favorites' }, { to: '/online-search', icon: , label: 'Online Search' }, { to: '/downloads', icon: , label: 'Downloads' }, { to: '/settings', icon: , label: 'Settings' }, ] const handleVolumeChange = (e: ChangeEvent) => { const newVolume = parseFloat(e.target.value) playback.setVolume(newVolume) } const handleVolumeScroll = (e: WheelEvent) => { e.stopPropagation() const step = e.deltaY > 0 ? -0.02 : 0.02 const newVolume = Math.max(0, Math.min(1, playback.volume + step)) playback.setVolume(newVolume) } useEffect(() => { const debounceTimer = setTimeout(() => { const fetchSearchResults = async () => { if (!searchQuery || !api.auth.serverUrl || !api.auth.token || !api.auth.userId) { setSearchResults([]) setSearchAttempted(false) return } setSearchLoading(true) setSearchError(null) setSearchAttempted(true) try { // Fetch artists from /Artists endpoint const [artistResponse, itemsResponse, genreResponse] = await Promise.all([ api.searchArtists(searchQuery, 20), api.searchItems(searchQuery, 40), api.searchGenres(searchQuery, 20), ]) // Fetch songs, albums, and playlists from /Items endpoint const artists = artistResponse.slice(0, 4) const songs = itemsResponse.filter(item => item.Type === 'Audio').slice(0, 6) const albums = itemsResponse.filter(item => item.Type === 'MusicAlbum').slice(0, 4) const playlists = itemsResponse.filter(item => item.Type === 'Playlist').slice(0, 4) const genres = genreResponse.slice(0, 4) const limitedResults = [...songs, ...artists, ...albums, ...playlists, ...genres] setSearchResults(limitedResults) } catch (err) { console.error('Search Error:', err) setSearchError('Failed to load search results') } finally { setSearchLoading(false) } } fetchSearchResults() }, 200) return () => clearTimeout(debounceTimer) }, [searchQuery, api.auth.serverUrl, api.auth.token, api.auth.userId, api]) const handleSearchChange = (e: ChangeEvent) => { setSearchQuery(e.target.value) } const handleClearSearch = () => { setSearchQuery('') setSearchResults([]) setSearchAttempted(false) } const handleSongClick = (song: MediaItem) => { if (song) { if (song.Id === playback.currentTrack?.Id) { playback.togglePlayPause() } else { playback.setCurrentPlaylistSimple({ playlist: [song], title: 'Sidenav Track' }) playback.playTrack(0) } closeSidenav() } } useEffect(() => { const focusSearch = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault() searchInputRef.current?.focus() } } window.addEventListener('keydown', focusSearch) return () => window.removeEventListener('keydown', focusSearch) }, []) return ( ) }