import { closestCenter, DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, TouchSensor, useSensor, useSensors, } from '@dnd-kit/core' import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' import { restrictToVerticalAxis } from '@dnd-kit/modifiers' import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { HeartFillIcon } from '@primer/octicons-react' import { InfiniteData } from '@tanstack/react-query' import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useNavigate } from 'react-router-dom' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' import { MediaItem } from '../api/jellyfin' import { useDropdownContext } from '../context/DropdownContext/DropdownContext' import { IMenuItems } from '../context/DropdownContext/DropdownContextProvider' import { usePlaybackContext } from '../context/PlaybackContext/PlaybackContext' import { useDownloadContext } from '../context/DownloadContext/DownloadContext' import { useFavorites } from '../hooks/useFavorites' import { IReviver } from './PlaybackManager' import { useDisplayItems } from '../hooks/useDisplayItems' import { JellyImg } from './JellyImg' import { Loader } from './Loader' import { UnifiedDownloadIndicator } from './UnifiedDownloadIndicator' import './MediaList.css' import { Skeleton } from './Skeleton' import { MediaDetailModal } from './MediaDetailModal' import { ErrorBoundary } from './ErrorBoundary' import { Squircle } from './Squircle' import { PlaystateAnimationMedalist } from './SvgIcons' export const MediaList = ({ items = [], infiniteData, isLoading, reviver, loadMore, hidden: _hidden = {}, disableActions = false, albumDisplayMode = 'artist', type, title, isDraggable, onSort, discoverMode = false, onDiscoverDownload, }: { items: MediaItem[] infiniteData?: InfiniteData isLoading: boolean reviver?: IReviver | 'persistAll' type: 'song' | 'album' | 'artist' | 'playlist' loadMore?: (startIndex: number) => void title: string isDraggable?: boolean onSort?: (items: MediaItem[]) => void disableActions?: boolean albumDisplayMode?: 'artist' | 'year' | 'both' hidden?: IMenuItems discoverMode?: boolean onDiscoverDownload?: (item: MediaItem) => void }) => { const [selectedDetailItem, setSelectedDetailItem] = useState(null) const [isDetailModalOpen, setIsDetailModalOpen] = useState(false) const playback = usePlaybackContext() const navigate = useNavigate() const dropdown = useDropdownContext() const virtuosoRef = useRef(null) const [scrollParent, setScrollParent] = useState(undefined) // Favorites hook moved to top-level so hooks are not called inside nested render functions const { addToFavorites, removeFromFavorites } = useFavorites() // Per-item swipe state stored in a ref map to avoid calling hooks inside renderItem const swipeRefs = useRef>({}) const handlePlayNext = useCallback(async (item: MediaItem) => { try { if (playback.currentTrackIndex >= 0) { await playback.updateCurrentPlaylist(async pages => { const flat = pages.flat() const insertAt = Math.min(playback.currentTrackIndex + 1, flat.length) flat.splice(insertAt, 0, { ...item }) const newPages: typeof pages = [] let offset = 0 for (const page of pages) { const len = page.length newPages.push(flat.slice(offset, offset + len)) offset += len } if (offset < flat.length) newPages.push(flat.slice(offset)) return newPages }) toast.success(`"${item.Name}" will play next`) } else if (infiniteData) { playback.setCurrentPlaylist({ pages: infiniteData, title, reviver }) playback.playTrack(0) toast.success(`Started playing "${item.Name}"`) } } catch (err) { console.error('Play next failed:', err) toast.error('Could not add to queue') } }, [playback, infiniteData, title, reviver]) const handleToggleFavorite = useCallback(async (item: MediaItem) => { try { if (item.UserData?.IsFavorite) { await removeFromFavorites(item) toast.success(`Removed "${item.Name}" from favorites`) } else { await addToFavorites(item) toast.success(`Added "${item.Name}" to favorites`) } } catch (err) { console.error('Toggle favorite failed:', err) toast.error('Could not update favorites') } }, [addToFavorites, removeFromFavorites]) const getSwipeHandlers = useCallback((item: MediaItem) => { const id = item.queueId || item.Id const onSwipeTouchStart: React.TouchEventHandler = e => { const touch = e.touches[0] swipeRefs.current[id] = { startX: touch.clientX, startY: touch.clientY, swiped: false, direction: null, } } const onSwipeTouchMove: React.TouchEventHandler = e => { const state = swipeRefs.current[id] if (!state || state.swiped) return const touch = e.touches[0] const dx = touch.clientX - state.startX const dy = touch.clientY - state.startY if (Math.abs(dy) > Math.abs(dx)) return const swipeThreshold = 80 if (dx < -swipeThreshold && !state.swiped) { state.swiped = true state.direction = 'left' handlePlayNext(item) } else if (dx > swipeThreshold && !state.swiped) { state.swiped = true state.direction = 'right' handleToggleFavorite(item) } } const onSwipeTouchEnd: React.TouchEventHandler = () => { setTimeout(() => { const state = swipeRefs.current[id] if (state) { state.swiped = false state.direction = null } }, 300) } return { onSwipeTouchStart, onSwipeTouchMove, onSwipeTouchEnd } }, [handlePlayNext, handleToggleFavorite]) const handleItemClick = (item: MediaItem, index: number) => { if (discoverMode) { // In discover mode, open the detail modal setSelectedDetailItem(item) setIsDetailModalOpen(true) } else { // Normal behavior for regular MediaList if (type === 'song') { const isCurrentTrack = playback.currentTrack?.Id === item.Id if (item.SpotifyId) { alert('Spotify tracks need to be downloaded before playback. Please use the download button first.') return } if (isCurrentTrack) { playback.togglePlayPause() } else { if (infiniteData) { playback.setCurrentPlaylist({ pages: infiniteData, title, reviver }) } playback.playTrack(index) } } else if (type === 'album') { navigate(`/album/${item.Id}`) } else if (type === 'artist') { navigate(`/artist/${item.Id}`) } else if (type === 'playlist') { navigate(`/playlist/${item.Id}`) } } } const closeDetailModal = () => { setIsDetailModalOpen(false) setSelectedDetailItem(null) } useEffect(() => { // Try multiple selectors to find the correct scroll parent const selectors = ['.main_content', '.main > .main_content', 'main > .main_content'] let foundParent = null for (const selector of selectors) { const element = document.querySelector(selector) as HTMLElement if (element) { foundParent = element break } } // Fallback to window if no specific scroll parent is found if (!foundParent) { console.warn('Could not find scroll parent, falling back to window') } setScrollParent(foundParent || undefined) }, [items.length]) const { displayItems, setRowRefs } = useDisplayItems(items, isLoading) const hidden: IMenuItems = disableActions ? { ..._hidden, next: true, add_to_queue: true, download_song: true, add_to_playlist: true } : _hidden const [activeId, setActiveId] = useState(null) const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id as string) }, []) const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event if (over && active.id !== over.id) { const oldIndex = items.findIndex(i => (i.queueId || i.Id) === active.id) const newIndex = items.findIndex(i => (i.queueId || i.Id) === over.id) const newItems = arrayMove(items, oldIndex, newIndex) onSort?.(newItems) } setActiveId(null) }, [items, onSort] ) const renderItem = ( index: number, item: MediaItem | { isPlaceholder: true }, listeners?: SyntheticListenerMap | undefined ) => { if ('isPlaceholder' in item) { if (type === 'album') { return (
setRowRefs(index, el)}>
) } else if (type === 'artist') { return (
setRowRefs(index, el)}>
) } else if (type === 'playlist') { return (
setRowRefs(index, el)}>
) } else { return (
setRowRefs(index, el)}>
) } } if (type === 'album') { const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen const itemClass = [isActive ? 'active' : ''].filter(Boolean).join(' ') return (
handleItemClick(item, index)} onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} onTouchStart={e => dropdown.onTouchStart(e, { item }, false, hidden)} onTouchMove={dropdown.onTouchClear} onTouchEnd={dropdown.onTouchClear} >
{item.Name}
{type === 'album' ? (
{albumDisplayMode === 'year' && item.ProductionYear ? ( <>{item.ProductionYear} ) : albumDisplayMode === 'both' && item.ProductionYear ? ( <> {item.AlbumArtist} {item.ProductionYear} ) : ( <>{item.AlbumArtist} )} {discoverMode && item.ChildCount && ( {item.ChildCount} tracks )}
) : ( <> {albumDisplayMode === 'year' &&
{item.ProductionYear}
} {albumDisplayMode === 'artist' && (
{item.AlbumArtist || 'Unknown Artist'}
)} {albumDisplayMode === 'both' && ( <>
{item.ProductionYear}
{item.AlbumArtist || 'Unknown Artist'}
)} )}
) } else if (type === 'artist') { const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen const itemClass = [isActive ? 'active' : ''].filter(Boolean).join(' ') return (
handleItemClick(item, index)} onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} onTouchStart={e => dropdown.onTouchStart(e, { item }, false, hidden)} onTouchMove={dropdown.onTouchClear} onTouchEnd={dropdown.onTouchClear} >
{item.Name}
) } else if (type === 'playlist') { const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen const itemClass = [isActive ? 'active' : ''].filter(Boolean).join(' ') return (
handleItemClick(item, index)} onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} onTouchStart={e => dropdown.onTouchStart(e, { item }, false, hidden)} onTouchMove={dropdown.onTouchClear} onTouchEnd={dropdown.onTouchClear} >
{item.Name}
{item.ChildCount || 0}{' '} {(item.ChildCount || 0) === 1 ? 'Track' : 'Tracks'}
) } else { const isCurrentTrack = playback.currentTrack?.Id === item.Id const isActive = dropdown.selectedItem?.Id === item.Id && dropdown.isOpen const itemClass = [ isCurrentTrack ? (playback.isPlaying ? 'playing' : 'paused') : '', isActive ? 'active' : '', ] .filter(Boolean) .join(' ') const { onSwipeTouchStart, onSwipeTouchMove, onSwipeTouchEnd } = getSwipeHandlers(item) // Use top-level handlers for play next and toggle favorite return (
handleItemClick(item, index)} ref={el => setRowRefs(index, el)} onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} onTouchStart={e => { // Handle both dropdown and swipe gestures dropdown.onTouchStart(e, { item }, false, hidden) onSwipeTouchStart(e) }} onTouchMove={e => { dropdown.onTouchClear() onSwipeTouchMove(e) }} onTouchEnd={e => { dropdown.onTouchClear() onSwipeTouchEnd(e) }} >
{item.Name} {/* Inline indicators right next to song name */} {/* Show existing track indicators */} {discoverMode && (item as any).existingTrackInfo?.exists && (
{(item as any).existingTrackInfo.total_versions > 1 ? ( {(item as any).existingTrackInfo.total_versions}✓ ) : ( ✓ )} {(item as any).existingTrackInfo.has_explicit && ( E )} {(item as any).existingTrackInfo.has_clean && ( C )}
)} {/* Show explicit marker */} {(() => { const trackName = item.Name?.toLowerCase() || ''; const isExplicit = ( (item as any).isExplicit || (item as any).Explicit || trackName.includes('explicit') || trackName.includes('uncensored') || trackName.includes('(explicit)') || trackName.includes('[explicit]') || trackName.includes('dirty') || trackName.includes('(dirty)') || trackName.includes('[dirty]') || (item.Path && ( item.Path.toLowerCase().includes('explicit') || item.Path.toLowerCase().includes('uncensored') || item.Path.toLowerCase().includes('dirty') )) ); return isExplicit; })() && ( E )} {/* Note: existingTrackInfo badges are only shown in discoverMode above */}
{item.Artists && item.Artists.length > 0 ? item.Artists.join(', ') : 'Unknown Artist'}
{item.Album || 'Unknown Album'}
) } } if (isLoading && items.length === 0) { return } if (items.length === 0 && !isLoading) { return (
{type === 'song' ? 'No tracks were found' : type === 'album' ? 'No albums were found' : type === 'artist' ? 'No artists were found' : 'No playlists were found'}
) } return (
    {isDraggable && ( { if ('isPlaceholder' in item) { return renderItem(index, item) } return ( renderItem(index, item, listeners)} /> ) }} endReached={loadMore ? (startIndex) => loadMore(startIndex) : undefined} overscan={800} /> )} {!isDraggable && ( loadMore(startIndex) : undefined} overscan={800} /> )} {/* Media Detail Modal */}

    Unable to load media details. Please try again.

    } >
) } const DraggableVirtuoso = ({ handleDragStart, handleDragEnd, items, renderItem, activeId, children, }: { handleDragStart: (event: DragStartEvent) => void handleDragEnd: (event: DragEndEvent) => void items: MediaItem[] renderItem: (index: number, item: MediaItem | { isPlaceholder: true }) => React.ReactNode activeId: string | null children: React.ReactNode }) => { const sensors = useSensors(useSensor(PointerSensor), useSensor(TouchSensor)) return ( i.queueId || i.Id)} strategy={verticalListSortingStrategy}> {children} {activeId ? renderItem( items.findIndex(i => (i.queueId || i.Id) === activeId), items.find(i => (i.queueId || i.Id) === activeId) || { isPlaceholder: true } ) : null} ) } const SortableItem = ({ id, cb, }: { id: string cb: (props: { listeners: SyntheticListenerMap | undefined }) => ReactNode }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) const style = { transform: CSS.Transform.toString(transform), transition: isDragging ? 'none' : transition, zIndex: isDragging ? 999 : undefined, opacity: isDragging ? 0 : 1, } const memoCb = useMemo(() => cb({ listeners }), [listeners, cb]) return (
  • {memoCb}
  • ) } const MediaIndicators = ({ item, disableActions, listeners, isDraggable, discoverMode = false, onDiscoverDownload, }: { item: MediaItem disableActions: boolean listeners?: SyntheticListenerMap | undefined isDraggable?: boolean discoverMode?: boolean onDiscoverDownload?: (item: MediaItem) => void }) => { // Import the DownloadContext const downloadContext = useDownloadContext() // Function to handle download for Discover page const handleDiscoverDownload = (item: MediaItem) => { console.log('Download initiated for:', item.Name) if (onDiscoverDownload) { // Use custom download handler if provided onDiscoverDownload(item) } else if (downloadContext) { // Use the existing download functionality downloadContext.addToDownloads([item], undefined) } } // Removed debug logging to clean up console return (
    {/* Single unified download indicator */} {!disableActions && item.UserData?.IsFavorite && location.pathname !== '/favorites' && (
    )} {isDraggable && (
    )}
    ) }