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 { IReviver } from './PlaybackManager' import { useDisplayItems } from '../hooks/useDisplayItems' import { JellyImg } from './JellyImg' import { Loader } from './Loader' import './MediaList.css' import { Skeleton } from './Skeleton' import { MediaDetailModal } from './MediaDetailModal' import { ErrorBoundary } from './ErrorBoundary' import { Squircle } from './Squircle' import { DeletingIcon, DownloadedIcon, DownloadingIcon, PlaystateAnimationMedalist, DownloadIcon, CheckIcon, SpinnerIcon } 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) 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(' ') // Right-edge swipe gesture state (per item render) let startX = 0 let startY = 0 let swiped = false const swipeThreshold = 48 // px left swipe const onSwipeTouchStart: React.TouchEventHandler = e => { e.stopPropagation() const t = e.touches[0] startX = t.clientX startY = t.clientY swiped = false } const onSwipeTouchMove: React.TouchEventHandler = e => { e.stopPropagation() const t = e.touches[0] const dx = t.clientX - startX const dy = t.clientY - startY // ignore if vertical motion dominates if (Math.abs(dy) > Math.abs(dx)) return if (!swiped && dx < -swipeThreshold) { swiped = true // Execute Up Next insertion try { if (playback.currentTrackIndex >= 0) { // Insert after current playback.updateCurrentPlaylist(async pages => { const flat = pages.flat() const insertAt = Math.min(playback.currentTrackIndex + 1, flat.length) // Insert a shallow copy of the item flat.splice(insertAt, 0, { ...(item as MediaItem) }) // Re-split to original page lengths 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 added to end created extra beyond last page length, append remainder as new page if (offset < flat.length) newPages.push(flat.slice(offset)) return Promise.resolve(newPages) }) toast.success('Added up next') } else { // Option 2: start new playlist with current list; make swiped item first if (infiniteData) { playback.setCurrentPlaylist({ pages: infiniteData, title, reviver }) // Move swiped to first then play playback.updateCurrentPlaylist(async pages => { const flat = pages.flat() const originalIndex = flat.findIndex(x => x.Id === (item as MediaItem).Id) if (originalIndex > 0) { const [moved] = flat.splice(originalIndex, 1) flat.unshift(moved) } else if (originalIndex === -1) { flat.unshift({ ...(item as MediaItem) }) } 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 Promise.resolve(newPages) }) playback.playTrack(0) } else { toast.info('Cannot start playback here. Open the list and try again.') } toast.success('Started playing with track first') } } catch (err) { console.error('Swipe Up Next failed:', err) toast.error('Could not add up next') } } } const onSwipeTouchEnd: React.TouchEventHandler = e => { e.stopPropagation() } return (
handleItemClick(item, index)} ref={el => setRowRefs(index, el)} onContextMenu={e => dropdown.onContextMenu(e, { item }, false, hidden)} onTouchStart={e => dropdown.onTouchStart(e, { item }, false, hidden)} onTouchMove={dropdown.onTouchClear} onTouchEnd={dropdown.onTouchClear} > {/* Right-edge swipe area to add Up Next */}
{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 )} {/* Show indicators for existing tracks - removed discoverMode check */} {(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 )}
)}
{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 = (e: React.MouseEvent) => { e.stopPropagation() console.log('Download button clicked!', { discoverMode, disableActions, item: item.Name }) // Check if track already exists and show confirmation const existingInfo = (item as any).existingTrackInfo; if (existingInfo?.exists) { const versionText = existingInfo.total_versions > 1 ? `${existingInfo.total_versions} versions` : '1 version'; const versionDetails = []; if (existingInfo.has_explicit) versionDetails.push('explicit'); if (existingInfo.has_clean) versionDetails.push('clean'); if (existingInfo.has_regular) versionDetails.push('regular'); const message = `"${item.Name}" already exists (${versionText}${versionDetails.length > 0 ? ': ' + versionDetails.join(', ') : ''}).\n\nDownload anyway?`; if (!confirm(message)) { return; } } if (onDiscoverDownload && item) { // Use custom download handler if provided onDiscoverDownload(item) } else if (downloadContext && item) { // Use the existing download functionality downloadContext.addToDownloads([item], undefined) } } // Removed debug logging to clean up console return (
    {item.offlineState && (
    {item.offlineState === 'downloading' && (
    {downloadContext?.downloads?.[item.Id]?.progress !== undefined ? ( <>
    {Math.round(downloadContext.downloads[item.Id].progress)}% ) : (
    )}
    )} {item.offlineState === 'downloaded' && (
    )} {item.offlineState === 'deleting' && (
    )} {item.offlineState === 'accepted' && (
    )} {item.offlineState === 'processing' && (
    )} {item.offlineState === 'finished' && (
    )} {item.offlineState === 'uploading' && (
    )}
    )} {/* Professional download button for discover mode */} {discoverMode && !disableActions && ( )} {/* Show download status for discover mode */} {discoverMode && item.offlineState === 'downloading' && (
    )} {/* Show downloaded status for discover mode */} {discoverMode && (item.offlineState === 'downloaded' || item.offlineState === 'finished') && (
    )} {!disableActions && item.UserData?.IsFavorite && location.pathname !== '/favorites' && (
    )} {isDraggable && (
    )}
    ) }