Files
editor/apps/editor/components/pascal-radio.tsx
T
2026-02-12 08:57:37 +09:00

246 lines
7.8 KiB
TypeScript

'use client'
import { Howl } from 'howler'
import { Disc3, Pause, Play, Settings2, SkipBack, SkipForward, Volume2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { Switch } from '@/components/ui/primitives/switch'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import useAudio from '@/store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [isPlaying, setIsPlaying] = useState(false)
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, autoplay, setAutoplay } = useAudio()
const soundRef = useRef<Howl | null>(null)
const hasAutoplayedRef = useRef(false)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler when track changes
useEffect(() => {
// Clean up previous sound
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlaying
// Create new sound
soundRef.current = new Howl({
src: [currentTrack.file],
volume: muted ? 0 : effectiveVolume,
onend: handleNext,
})
// If was playing, play new track
if (wasPlaying && !muted) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isPlaying) {
soundRef.current.pause()
} else if (!muted && isPlaying && !soundRef.current.playing()) {
soundRef.current.play()
}
}
}, [effectiveVolume, muted, isPlaying])
// Autoplay on first user click
useEffect(() => {
if (!autoplay || hasAutoplayedRef.current || muted) return
const handleFirstClick = () => {
if (!soundRef.current || hasAutoplayedRef.current) return
hasAutoplayedRef.current = true
soundRef.current.play()
setIsPlaying(true)
// Remove listener after first click
document.removeEventListener('click', handleFirstClick)
}
document.addEventListener('click', handleFirstClick)
return () => {
document.removeEventListener('click', handleFirstClick)
}
}, [autoplay, muted])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setIsPlaying(!isPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md">
<Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} />
<span className="hidden sm:inline">Pascal Radio</span>
<div
onClick={handlePlayPause}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
role="button"
tabIndex={0}
aria-label={isPlaying ? 'Pause' : 'Play'}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handlePlayPause()
}
}}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
</div>
<Popover>
<PopoverTrigger asChild>
<button
className="rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground"
aria-label="Radio Settings"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-3">
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
onClick={handlePrevious}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Previous"
>
<SkipBack className="h-4 w-4" />
</button>
<p className="text-sm font-medium text-center flex-1 truncate">{currentTrack.title}</p>
<button
onClick={handleNext}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Next"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
</div>
{/* Autoplay setting */}
<div className="flex items-center justify-between pt-2 border-t">
<label htmlFor="autoplay" className="text-sm font-medium cursor-pointer">
Autoplay
</label>
<Switch
id="autoplay"
checked={autoplay}
onCheckedChange={setAutoplay}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)
}