Eidos

Installation Notice

The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.

Music Player

By: Mayne

Install Latest (v0.0.2)

Mount a folder named "music" that contains audio files, and this block will start working.

import React, { useState, useEffect, useRef } from "react"
import { Play, Pause, SkipBack, SkipForward, Volume2, Music, ListMusic, Disc, Search, RefreshCw, ChevronDown, ChevronUp } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Input } from "@/components/ui/input"
import { parseBlob } from "music-metadata"

const MUSIC_DIR = "@/music"
const CACHE_KEY = 'music-player-metadata-cache-v1'
const CACHE_VERSION = '1.0'

// Helper function to get cache key for individual file
const getFileCacheKey = (file) => `${CACHE_KEY}:${file}`

// Helper function to convert ArrayBuffer to base64
const arrayBufferToBase64 = (buffer) => {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  return btoa(binary)
}

// Define consistent grid layout for header and rows to ensure perfect alignment
// Mobile: Index (40px) | Title (1fr)
// Desktop: Index (50px) | Title (6fr) | Artist (4fr) | Album (3fr)
const GRID_LAYOUT = "grid grid-cols-[40px_1fr] sm:grid-cols-[50px_6fr_4fr_3fr] gap-4 px-4"

export default function MusicPlayer() {
  const [files, setFiles] = useState([])
  const [currentTrackIndex, setCurrentTrackIndex] = useState(-1)
  const [isPlaying, setIsPlaying] = useState(false)
  const [progress, setProgress] = useState(0)
  const [duration, setDuration] = useState(0)
  const [volume, setVolume] = useState(1)
  const [metadataCache, setMetadataCache] = useState({})
  const [searchQuery, setSearchQuery] = useState("")
  const [sortBy, setSortBy] = useState("title") // title, artist, album
  const [sortOrder, setSortOrder] = useState("asc") // asc, desc

  const audioRef = useRef(null)

  // Load file list
  useEffect(() => {
    const loadFiles = async () => {
      try {
        const fileList = await eidos.currentSpace.fs.readdir(MUSIC_DIR)
        // Filter for audio files
        const audioFiles = fileList.filter(f =>
          /\.(mp3|wav|flac|m4a|ogg)$/i.test(f)
        )
        setFiles(audioFiles)
      } catch (err) {
        console.error("Failed to load music directory:", err)
        eidos.currentSpace.notify({
          title: "Error",
          description: "Could not load music from @/music"
        })
      }
    }
    loadFiles()
  }, [])

  // Load metadata from localStorage on mount
  useEffect(() => {
    const loadCache = () => {
      const cache = {}
      try {
        // Load cached metadata for each file
        files.forEach(file => {
          const cached = localStorage.getItem(getFileCacheKey(file))
          if (cached) {
            try {
              const parsed = JSON.parse(cached)
              if (parsed._version === CACHE_VERSION) {
                cache[file] = parsed.data
              }
            } catch (e) {
              // Invalid cache entry, remove it
              localStorage.removeItem(getFileCacheKey(file))
            }
          }
        })
        setMetadataCache(cache)
      } catch (e) {
        console.warn("Failed to load metadata cache:", e)
      }
    }
    
    if (files.length > 0) {
      loadCache()
    }
  }, [files])

  // Load metadata progressively with individual caching
  useEffect(() => {
    if (files.length === 0) return

    let isMounted = true
    const loadMetadata = async () => {
      // Only load metadata for files not in cache
      const filesToLoad = files.filter(file => !metadataCache[file])
      if (filesToLoad.length === 0) return

      for (const file of filesToLoad) {
        if (!isMounted) break

        try {
          const url = `${MUSIC_DIR}/${file}`
          const response = await fetch(url)
          const blob = await response.blob()
          const metadata = await parseBlob(blob)

          let coverUrl = null
          if (metadata.common.picture?.[0]) {
            const pic = metadata.common.picture[0]
            // Convert to base64 string for caching
            const base64 = arrayBufferToBase64(pic.data)
            coverUrl = `data:${pic.format};base64,${base64}`
          }

          const fileMetadata = {
            title: metadata.common.title || file,
            artist: metadata.common.artist || "Unknown Artist",
            album: metadata.common.album || "Unknown Album",
            coverUrl: coverUrl
          }

          if (isMounted) {
            // Update state immediately
            setMetadataCache(prev => ({ ...prev, [file]: fileMetadata }))
            
            // Save to localStorage individually
            try {
              localStorage.setItem(getFileCacheKey(file), JSON.stringify({
                _version: CACHE_VERSION,
                data: fileMetadata
              }))
            } catch (e) {
              console.warn(`Failed to save metadata cache for ${file}:`, e)
            }
          }
        } catch (e) {
          console.warn(`Failed to parse metadata for ${file}`, e)
          // Cache basic info even if parsing fails
          const fallbackMetadata = {
            title: file,
            artist: "Unknown Artist",
            album: "Unknown Album",
            coverUrl: null
          }
          
          if (isMounted) {
            setMetadataCache(prev => ({ ...prev, [file]: fallbackMetadata }))
            try {
              localStorage.setItem(getFileCacheKey(file), JSON.stringify({
                _version: CACHE_VERSION,
                data: fallbackMetadata
              }))
            } catch (cacheError) {
              console.warn(`Failed to save fallback metadata for ${file}:`, cacheError)
            }
          }
        }
      }
    }

    // Start loading metadata with a small delay to let UI render
    const timeout = setTimeout(loadMetadata, 500)
    return () => {
      isMounted = false
      clearTimeout(timeout)
    }
  }, [files, metadataCache])

  // Add refresh metadata function
  const refreshMetadata = () => {
    // Clear all individual cache entries
    files.forEach(file => {
      localStorage.removeItem(getFileCacheKey(file))
    })
    setMetadataCache({})
    eidos.currentSpace.notify({
      title: "info",
      description: "Metadata cache cleared.Reloading..."
    })

    // Trigger reload by resetting files
    setFiles(prev => [...prev])
  }

  // Filter and sort files
  const filteredAndSortedFiles = React.useMemo(() => {
    let filtered = files.filter(file => {
      if (!searchQuery.trim()) return true
      const meta = metadataCache[file] || {}
      const query = searchQuery.toLowerCase()
      return (
        (meta.title || file).toLowerCase().includes(query) ||
        (meta.artist || "").toLowerCase().includes(query) ||
        (meta.album || "").toLowerCase().includes(query)
      )
    })

    // Sort files
    filtered.sort((a, b) => {
      const metaA = metadataCache[a] || { title: a, artist: "Unknown Artist", album: "Unknown Album" }
      const metaB = metadataCache[b] || { title: b, artist: "Unknown Artist", album: "Unknown Album" }
      
      let aValue, bValue
      switch (sortBy) {
        case "artist":
          aValue = metaA.artist
          bValue = metaB.artist
          break
        case "album":
          aValue = metaA.album
          bValue = metaB.album
          break
        default:
          aValue = metaA.title
          bValue = metaB.title
      }

      const comparison = aValue.localeCompare(bValue, undefined, { sensitivity: 'base' })
      return sortOrder === "asc" ? comparison : -comparison
    })

    return filtered
  }, [files, metadataCache, searchQuery, sortBy, sortOrder])

  // Audio controls
  useEffect(() => {
    if (currentTrackIndex >= 0 && audioRef.current) {
      if (isPlaying) {
        audioRef.current.play().catch(e => console.error("Play error:", e))
      } else {
        audioRef.current.pause()
      }
    }
  }, [currentTrackIndex, isPlaying])

  const playTrack = (index) => {
    // Find the actual file index in the original files array
    const displayedFile = filteredAndSortedFiles[index]
    const actualIndex = files.indexOf(displayedFile)
    
    if (actualIndex === currentTrackIndex) {
      setIsPlaying(!isPlaying)
    } else {
      setCurrentTrackIndex(actualIndex)
      setIsPlaying(true)
    }
  }

  const nextTrack = () => {
    if (currentTrackIndex < files.length - 1) {
      setCurrentTrackIndex(prev => prev + 1)
    } else {
      setCurrentTrackIndex(0) // Loop
    }
  }

  const prevTrack = () => {
    if (currentTrackIndex > 0) {
      setCurrentTrackIndex(prev => prev - 1)
    }
  }

  const handleTimeUpdate = () => {
    if (audioRef.current) {
      setProgress(audioRef.current.currentTime)
      setDuration(audioRef.current.duration || 0)
    }
  }

  const handleEnded = () => {
    nextTrack()
  }

  const formatTime = (time) => {
    if (!time) return "0:00"
    const minutes = Math.floor(time / 60)
    const seconds = Math.floor(time % 60)
    return `${minutes}:${seconds.toString().padStart(2, '0')}`
  }

  const currentFile = files[currentTrackIndex]
  const currentMeta = currentFile ? (metadataCache[currentFile] || { title: currentFile, artist: "Loading..." }) : null

  return (
    <div className="flex flex-col h-screen bg-background text-foreground font-sans overflow-hidden">
      {/* Main Content Area */}
      <div className="flex flex-1 overflow-hidden">
        {/* Sidebar */}
        <div className="w-64 bg-card hidden md:flex flex-col p-6 gap-6 border-r border-border">
          <div className="flex items-center gap-2 text-foreground font-bold text-xl px-2">
            <Music className="w-8 h-8 text-primary" />
            <span>Music</span>
          </div>
          
          {/* Search */}
          <div className="relative">
            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
            <Input
              placeholder="Search..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              className="pl-9 bg-background border-border"
            />
          </div>

          <div className="flex flex-col gap-4">
            <Button variant="ghost" className="justify-start text-muted-foreground hover:text-foreground hover:bg-accent px-2 font-medium">
              <ListMusic className="mr-2 h-5 w-5" />
              Library
            </Button>
            <Button variant="ghost" className="justify-start text-muted-foreground hover:text-foreground hover:bg-accent px-2 font-medium">
              <Search className="mr-2 h-5 w-5" />
              Search
            </Button>
          </div>
          <div className="mt-auto space-y-3">
            <Button
              variant="outline"
              size="sm"
              className="w-full justify-start text-muted-foreground hover:text-foreground border-border"
              onClick={refreshMetadata}
            >
              <RefreshCw className="mr-2 h-4 w-4" />
              Refresh Metadata
            </Button>
            <div className="bg-muted rounded-lg p-4 border border-border">
              <p className="text-sm text-muted-foreground font-medium">Mounted at</p>
              <p className="font-mono text-xs text-muted-foreground/70 mt-1">@/music</p>
            </div>
          </div>
        </div>

        {/* Track List */}
        <div className="flex-1 bg-background overflow-hidden flex flex-col">
          <header className="h-20 flex items-end px-8 pb-4 sticky top-0 bg-gradient-to-b from-card to-background z-10">
            <div className="flex items-center gap-4">
              <div className="w-10 h-10 bg-primary rounded flex items-center justify-center shadow-lg shadow-primary/20">
                <ListMusic className="text-primary-foreground w-6 h-6" />
              </div>
              <h1 className="text-3xl font-bold text-foreground tracking-tight">My Library</h1>
            </div>
          </header>

          <ScrollArea className="flex-1">
            <div className="pb-6">
              {/* Header Row - Clickable sorting headers */}
              <div className={`sticky top-0 z-10 bg-background/95 backdrop-blur py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground border-b border-border mb-2 ${GRID_LAYOUT}`}>
                <span className="text-center">#</span>
                <button 
                  onClick={() => {
                    if (sortBy === "title") {
                      setSortOrder(prev => prev === "asc" ? "desc" : "asc")
                    } else {
                      setSortBy("title")
                      setSortOrder("asc")
                    }
                  }}
                  className="flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
                >
                  Title
                  {sortBy === "title" && (
                    sortOrder === "asc" ? 
                      <ChevronUp className="w-3 h-3" /> : 
                      <ChevronDown className="w-3 h-3" />
                  )}
                </button>
                <button 
                  onClick={() => {
                    if (sortBy === "artist") {
                      setSortOrder(prev => prev === "asc" ? "desc" : "asc")
                    } else {
                      setSortBy("artist")
                      setSortOrder("asc")
                    }
                  }}
                  className="hidden sm:flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
                >
                  Artist
                  {sortBy === "artist" && (
                    sortOrder === "asc" ? 
                      <ChevronUp className="w-3 h-3" /> : 
                      <ChevronDown className="w-3 h-3" />
                  )}
                </button>
                <button 
                  onClick={() => {
                    if (sortBy === "album") {
                      setSortOrder(prev => prev === "asc" ? "desc" : "asc")
                    } else {
                      setSortBy("album")
                      setSortOrder("asc")
                    }
                  }}
                  className="hidden sm:flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
                >
                  Album
                  {sortBy === "album" && (
                    sortOrder === "asc" ? 
                      <ChevronUp className="w-3 h-3" /> : 
                      <ChevronDown className="w-3 h-3" />
                  )}
                </button>
              </div>

              <div className="px-2">
                {filteredAndSortedFiles.map((file, index) => {
                  const meta = metadataCache[file] || {}
                  const isCurrent = file === currentFile

                  return (
                    <div
                      key={file}
                      onClick={() => playTrack(index)}
                      className={`group ${GRID_LAYOUT} py-2 rounded-md items-center cursor-pointer transition-all duration-200 hover:bg-accent/50 ${isCurrent ? 'bg-accent/80' : ''
                        }`}
                    >
                      <div className="text-center flex justify-center items-center h-full">
                        {isCurrent && isPlaying ? (
                          <div className="w-3 h-3 bg-primary rounded-full animate-pulse" />
                        ) : (
                          <>
                            <span className={`text-sm font-medium tabular-nums group-hover:hidden ${isCurrent ? 'text-primary' : 'text-muted-foreground'}`}>
                              {index + 1}
                            </span>
                            <Play className="w-4 h-4 hidden group-hover:block text-foreground fill-foreground" />
                          </>
                        )}
                      </div>

                      <div className="flex items-center gap-4 min-w-0 overflow-hidden">
                        {meta.coverUrl ? (
                          <img
                            src={meta.coverUrl}
                            className="w-10 h-10 rounded shadow-sm object-cover bg-muted shrink-0"
                            alt=""
                          />
                        ) : (
                          <div className="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
                            <Disc className="w-5 h-5 text-muted-foreground" />
                          </div>
                        )}
                        <div className="flex flex-col min-w-0 truncate">
                          <span className={`font-medium truncate text-sm ${isCurrent ? 'text-primary' : 'text-foreground'}`}>
                            {meta.title || file}
                          </span>
                          <span className="text-xs text-muted-foreground sm:hidden truncate">
                            {meta.artist || "Unknown"}
                          </span>
                        </div>
                      </div>

                      <span className="hidden sm:block truncate text-sm text-muted-foreground hover:text-foreground transition-colors">
                        {meta.artist || "Unknown Artist"}
                      </span>

                      <span className="hidden sm:block truncate text-sm text-muted-foreground hover:text-foreground transition-colors">
                        {meta.album || "Unknown Album"}
                      </span>
                    </div>
                  )
                })}
              </div>

              {filteredAndSortedFiles.length === 0 && (
                <div className="text-center py-20 text-muted-foreground">
                  <Music className="w-16 h-16 mx-auto mb-4 opacity-20" />
                  <p>{searchQuery ? "No results found" : "No audio files found in @/music"}</p>
                </div>
              )}
            </div>
          </ScrollArea>
        </div>
      </div>

      {/* Player Bar */}
      <div className="h-24 bg-card/95 backdrop-blur border-t border-border px-6 flex items-center justify-between z-20">
        {/* Current Track Info */}
        <div className="flex items-center gap-4 w-[30%] min-w-[180px]">
          {currentFile && (
            <>
              {currentMeta?.coverUrl ? (
                <img src={currentMeta.coverUrl} className="w-14 h-14 rounded-md shadow-md object-cover border border-border" alt="Cover" />
              ) : (
                <div className="w-14 h-14 rounded-md bg-muted flex items-center justify-center border border-border shrink-0">
                  <Music className="w-6 h-6 text-muted-foreground" />
                </div>
              )}
              <div className="flex flex-col min-w-0 overflow-hidden">
                <span className="text-sm font-semibold text-foreground truncate cursor-default hover:underline">
                  {currentMeta?.title || currentFile}
                </span>
                <span className="text-xs text-muted-foreground truncate cursor-default hover:text-foreground">
                  {currentMeta?.artist || "Unknown Artist"}
                </span>
              </div>
            </>
          )}
        </div>

        {/* Controls */}
        <div className="flex flex-col items-center w-[40%] max-w-xl gap-2">
          <div className="flex items-center gap-6">
            <Button variant="ghost" size="icon" onClick={prevTrack} className="text-muted-foreground hover:text-foreground hover:bg-transparent">
              <SkipBack className="w-5 h-5 fill-current" />
            </Button>
            <Button
              size="icon"
              className="w-10 h-10 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 hover:scale-105 transition-all shadow-lg shadow-primary/10"
              onClick={() => setIsPlaying(!isPlaying)}
              disabled={!currentFile}
            >
              {isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-1" />}
            </Button>
            <Button variant="ghost" size="icon" onClick={nextTrack} className="text-muted-foreground hover:text-foreground hover:bg-transparent">
              <SkipForward className="w-5 h-5 fill-current" />
            </Button>
          </div>

          <div className="flex items-center gap-2 w-full group">
            <span className="text-xs text-muted-foreground w-10 text-right font-mono tabular-nums">{formatTime(progress)}</span>
            <Slider
              value={[progress]}
              max={duration || 100}
              step={1}
              className="w-full cursor-pointer"
              onValueChange={(val) => {
                if (audioRef.current) audioRef.current.currentTime = val[0]
                setProgress(val[0])
              }}
            />
            <span className="text-xs text-muted-foreground w-10 font-mono tabular-nums">{formatTime(duration)}</span>
          </div>
        </div>

        {/* Volume */}
        <div className="flex items-center justify-end gap-2 w-[30%] min-w-[140px]">
          <Volume2 className="w-5 h-5 text-muted-foreground" />
          <div className="w-24">
            <Slider
              value={[volume * 100]}
              max={100}
              onValueChange={(val) => {
                const newVol = val[0] / 100
                setVolume(newVol)
                if (audioRef.current) audioRef.current.volume = newVol
              }}
            />
          </div>
        </div>
      </div>

      {/* Hidden Audio Element */}
      {currentFile && (
        <audio
          ref={audioRef}
          src={`${MUSIC_DIR}/${currentFile}`}
          onTimeUpdate={handleTimeUpdate}
          onEnded={handleEnded}
          onLoadedMetadata={handleTimeUpdate}
        />
      )}
    </div>
  )
}

Information

Author
Mayne
Type
block
Latest Version
0.0.2
Last Updated
11/20/2025
Published
11/19/2025

Version History

  • v0.0.2 11/20/2025
  • v0.0.1 11/19/2025