The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.
By: Mayne
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>
)
}