'use client' import React, { useState, useRef, useEffect, useMemo } from 'react' import dynamic from 'next/dynamic' import { X, Save, Settings } from 'lucide-react' import { soundEffects, DEFAULT_CHANNELS } from '@/lib/lofi_data' import ChannelButtons from '@/components/ChannelButtons' import PlaybackControls from '@/components/PlaybackControls' import ChannelManagement from '@/components/ChannelManagement' import SoundEffectsControls from '@/components/SoundEffectsControls' import { useLocalStorage } from '@/hooks/useLocalStorage' import { Channel, CustomSoundEffect } from '@/types/lofi' import SettingsModal from '@/components/SettingsModal' import styles from '@/styles/Lofi.module.css' const ReactPlayer = dynamic(() => import('react-player/youtube'), { ssr: false, }) // Type Definitions type AudioCache = { audio: HTMLAudioElement loaded: boolean } const StaticEffect = () => { const [staticPoints, setStaticPoints] = useState< { left: string; top: string; opacity: number }[] >([]) useEffect(() => { setStaticPoints( Array.from({ length: 100 }, () => ({ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, opacity: Math.random() * 0.5, })) ) }, []) return (
{staticPoints.map((point, i) => (
))}
) } const EnhancedLofiPlayer = () => { const [mounted, setMounted] = useState(false) const [currentChannel, setCurrentChannel] = useState(0) const [isPlaying, setIsPlaying] = useState(true) const [volume, setVolume] = useLocalStorage('lofi-volume', 0.7) const [played, setPlayed] = useState(0) const [currentTheme, setCurrentTheme] = useLocalStorage( 'lofi-theme', 'dark' ) const [effectsVolume, setEffectsVolume] = useLocalStorage( 'lofi-effects-volume', 0.5 ) const [customChannels, setCustomChannels] = useLocalStorage( 'customChannels', [] ) const [hiddenDefaultChannels, setHiddenDefaultChannels] = useLocalStorage< number[] >('hiddenDefaultChannels', []) const [effectVolumes, setEffectVolumes] = useLocalStorage<{ [key: string]: number }>( 'lofi-effect-volumes', Object.fromEntries(soundEffects.map((effect) => [effect.id, 0.5])) ) const [customEffects, setCustomEffects] = useLocalStorage< CustomSoundEffect[] >('customSoundEffects', []) const playerRef = useRef(null) const audioRefs = useRef<{ [key: string]: AudioCache }>({}) const [activeEffects, setActiveEffects] = useState>(new Set()) const [loadingEffects, setLoadingEffects] = useState>(new Set()) const [isAddingChannel, setIsAddingChannel] = useState(false) const [newChannel, setNewChannel] = useState({ name: '', url: '', description: '', creator: '', isCustom: true, }) const [showDeleteConfirm, setShowDeleteConfirm] = useState( null ) const [isEditingChannel, setIsEditingChannel] = useState(null) const [editingChannel, setEditingChannel] = useState({ name: '', url: '', description: '', creator: '', isCustom: true, }) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const isBrowser = typeof window !== 'undefined' useEffect(() => { if (!isBrowser) return setMounted(true) }, [isBrowser]) useEffect(() => { Object.entries(audioRefs.current).forEach(([effectId, cache]) => { if (cache?.audio) { cache.audio.volume = effectVolumes[effectId] * effectsVolume } }) }, [effectVolumes, effectsVolume]) // Function to load audio on demand const loadAudio = async (effectId: string) => { const effect = soundEffects.find((e) => e.id === effectId) if (!effect || effect.isYoutube) return if (loadingEffects.has(effectId) || audioRefs.current[effectId]?.loaded) { return } setLoadingEffects((prev) => new Set(prev).add(effectId)) try { const audio = new Audio() const loadPromise = new Promise((resolve, reject) => { audio.addEventListener('canplaythrough', () => resolve(), { once: true, }) audio.addEventListener( 'error', (error: ErrorEvent) => { reject( new Error( `Audio loading error: ${error.message || 'Unknown error'}` ) ) }, { once: true } ) audio.src = effect.file audio.load() }) await loadPromise audio.loop = true audio.volume = effectVolumes[effectId] * effectsVolume audioRefs.current[effectId] = { audio, loaded: true, } } catch (error) { console.error(`Failed to load audio for ${effectId}:`, error) } finally { setLoadingEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) } } // Toggle effect function const toggleEffect = async (effectId: string) => { const effect = soundEffects.find((e) => e.id === effectId) || customEffects.find((e) => e.id === effectId) if (!effect) return try { if (effect.isYoutube) { // For YouTube effects, just toggle the active state setActiveEffects((prev) => { const newEffects = new Set(prev) if (newEffects.has(effectId)) { newEffects.delete(effectId) } else { newEffects.add(effectId) } return newEffects }) return } // Handle native audio effects if (!audioRefs.current[effectId]?.loaded) { await loadAudio(effectId) } const audioCache = audioRefs.current[effectId] if (!audioCache?.audio) return setActiveEffects((prev) => { const newEffects = new Set(prev) if (newEffects.has(effectId)) { newEffects.delete(effectId) audioCache.audio.pause() } else { newEffects.add(effectId) audioCache.audio.play().catch((error) => { console.error('Error playing audio:', error) newEffects.delete(effectId) }) } return newEffects }) } catch (error) { console.error('Error toggling effect:', error) // Clear loading state and active state on error setLoadingEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) setActiveEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) } } const handleProgress = (state: { played: number }) => { if (!isPlaying) return setPlayed(state.played) } const handleChannelChange = (index: number) => { setCurrentChannel(index) setPlayed(0) } const handleVolumeChange = (newVolume: number) => { setVolume(newVolume) } const handleEffectsVolumeChange = (newVolume: number) => { setEffectsVolume(newVolume) // Update all active effect volumes Object.entries(audioRefs.current).forEach(([effectId, cache]) => { if (cache?.audio) { cache.audio.volume = effectVolumes[effectId] * newVolume } }) } const handleThemeChange = (theme: string) => { setCurrentTheme(theme) } const handleAddChannel = () => { if (!newChannel.name || !newChannel.url) { alert('Channel Name and URL are required.') return } const updatedChannels: Channel[] = [ ...customChannels, { ...newChannel, isCustom: true }, ] setCustomChannels(updatedChannels) setIsAddingChannel(false) setNewChannel({ name: '', url: '', description: '', creator: '', isCustom: true, }) } const handleDeleteChannel = (channelIndex: number) => { const channelToDelete = allChannels[channelIndex] if (!channelToDelete) return let newChannelIndex = channelIndex if ( !channelToDelete.isCustom && typeof channelToDelete.originalIndex === 'number' ) { // It's a default channel if (!hiddenDefaultChannels.includes(channelToDelete.originalIndex)) { const updatedHidden = [ ...hiddenDefaultChannels, channelToDelete.originalIndex, ] setHiddenDefaultChannels(updatedHidden) } } else { // It's a custom channel const updatedChannels = customChannels.filter( (channel) => channel.name !== channelToDelete.name || channel.url !== channelToDelete.url ) setCustomChannels(updatedChannels) } // Switch channel if needed if (channelIndex === currentChannel) { newChannelIndex = Math.max(0, channelIndex - 1) setCurrentChannel(newChannelIndex) } setShowDeleteConfirm(null) } const changeChannel = (direction: 'next' | 'prev') => { setCurrentChannel((prev) => { if (direction === 'next') { return (prev + 1) % allChannels.length } else { return (prev - 1 + allChannels.length) % allChannels.length } }) } const allChannels = useMemo(() => { // Get visible default channels const visibleDefaultChannels = DEFAULT_CHANNELS.map((channel, index) => ({ ...channel, isCustom: false, originalIndex: index, })).filter((_, index) => !hiddenDefaultChannels.includes(index)) // Add custom channels return [...visibleDefaultChannels, ...customChannels] }, [customChannels, hiddenDefaultChannels]) const handleSaveChannel = () => { if (!newChannel.name || !newChannel.url) { alert('Channel Name and URL are required.') return } const updatedChannels: Channel[] = [ ...customChannels, { ...newChannel, isCustom: true }, ] setCustomChannels(updatedChannels) setIsAddingChannel(false) setNewChannel({ name: '', url: '', description: '', creator: '', isCustom: true, }) } const handleEditChannel = (channelIndex: number) => { console.log('Starting edit for channel:', { channelIndex, channel: allChannels[channelIndex], }) const channel = allChannels[channelIndex] setEditingChannel({ ...channel }) setIsEditingChannel(channelIndex) } const handleSaveEditedChannel = () => { if (!editingChannel.name || !editingChannel.url) { alert('Channel Name and URL are required.') return } const channelToEdit = allChannels[isEditingChannel ?? -1] if (!channelToEdit) return if (channelToEdit.isCustom) { // Editing a custom channel const customIndex = customChannels.findIndex( (channel) => channel.name === channelToEdit.name && channel.url === channelToEdit.url ) if (customIndex !== -1) { const updatedChannels = [...customChannels] updatedChannels[customIndex] = { ...editingChannel, isCustom: true } setCustomChannels(updatedChannels) } } else if (typeof channelToEdit.originalIndex === 'number') { // Editing a default channel - hide default and add as custom if (!hiddenDefaultChannels.includes(channelToEdit.originalIndex)) { const updatedHidden = [ ...hiddenDefaultChannels, channelToEdit.originalIndex, ] setHiddenDefaultChannels(updatedHidden) } const updatedChannels = [ ...customChannels, { ...editingChannel, isCustom: true }, ] setCustomChannels(updatedChannels) } setIsEditingChannel(null) setEditingChannel({ name: '', url: '', description: '', creator: '', isCustom: true, }) } const handleResetDefaults = () => { if ( window.confirm( 'Are you sure you want to reset to default settings? This will remove all custom channels and effects.' ) ) { setCustomChannels([]) setHiddenDefaultChannels([]) setCustomEffects([]) setCurrentChannel(0) setVolume(0.7) setEffectsVolume(0.5) setEffectVolumes( Object.fromEntries(soundEffects.map((effect) => [effect.id, 0.5])) ) setCurrentTheme('dark') } } // Add useEffect to initialize and handle theme changes useEffect(() => { if (typeof window !== 'undefined') { // Get theme from localStorage directly to ensure immediate application const savedTheme = localStorage.getItem('lofi-theme') || 'dark' // Apply theme to root element document.documentElement.dataset.theme = savedTheme // Update state if different if (currentTheme !== savedTheme) { setCurrentTheme(savedTheme) } } }, []) // Run only on mount // Add useEffect to handle theme changes useEffect(() => { if (currentTheme) { // Update root element when theme changes document.documentElement.dataset.theme = currentTheme // Also update localStorage directly localStorage.setItem('lofi-theme', currentTheme) } }, [currentTheme]) const handleLoadEffect = async (effectId: string, file: string) => { if (audioRefs.current[effectId]?.loaded) return setLoadingEffects((prev) => new Set([...prev, effectId])) try { const audio = new Audio(file) await audio.load() audio.loop = true audioRefs.current[effectId] = { audio, loaded: true } } catch (error) { console.error(`Error loading effect ${effectId}:`, error) } finally { setLoadingEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) } } const handleToggleEffect = async (effectId: string, file: string) => { if (!audioRefs.current[effectId]?.loaded) { await handleLoadEffect(effectId, file) } const audio = audioRefs.current[effectId]?.audio if (!audio) return if (activeEffects.has(effectId)) { audio.pause() setActiveEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) } else { audio.volume = effectVolumes[effectId] * effectsVolume audio.play() setActiveEffects((prev) => new Set([...prev, effectId])) } } const handleEffectVolumeChange = (effectId: string, volume: number) => { const newVolumes = { ...effectVolumes, [effectId]: volume } setEffectVolumes(newVolumes) const audio = audioRefs.current[effectId]?.audio if (audio) { audio.volume = volume * effectsVolume } } const handleAddCustomEffect = (effect: CustomSoundEffect) => { setCustomEffects((prev) => [...prev, effect]) } const handleRemoveCustomEffect = (effectId: string) => { setCustomEffects((prev) => prev.filter((effect) => effect.id !== effectId)) // Stop and remove the audio if it's playing const audio = audioRefs.current[effectId]?.audio if (audio) { audio.pause() delete audioRefs.current[effectId] } setActiveEffects((prev) => { const next = new Set(prev) next.delete(effectId) return next }) } return (
{/* Retro TV */}
{mounted && } {mounted && ( // @ts-ignore console.error('Player error:', error) } config={{ playerVars: { controls: 0, modestbranding: 1, iv_load_policy: 3, rel: 0, showinfo: 0, }, }} /> )}
LIVE
CH{currentChannel + 1}
{/* Main Controls Section */}
{/* Channel Information */}
{/* Settings button */}
{mounted ? ( <>

{allChannels[currentChannel].name}

{allChannels[currentChannel].description}

by {allChannels[currentChannel].creator}

) : ( <>

{DEFAULT_CHANNELS[0].name}

{DEFAULT_CHANNELS[0].description}

by {DEFAULT_CHANNELS[0].creator}

)}
{/* Channel Buttons */} {mounted && (
)} {/* Controls Container */}
{/* Left Side - Playback Controls */} {mounted && ( )} {/* Right Side - Channel Management */} {mounted && ( )}
{/* Progress Bar */}
{/* Sound Effects Section - Separated into its own card */} {mounted && (
)}
{/* Channel Edit Modal */} {isEditingChannel !== null && (

Edit Channel {isEditingChannel + 1}

setEditingChannel({ ...editingChannel, name: e.target.value, }) } className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} /> setEditingChannel({ ...editingChannel, url: e.target.value, }) } className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} /> setEditingChannel({ ...editingChannel, description: e.target.value, }) } className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} /> setEditingChannel({ ...editingChannel, creator: e.target.value, }) } className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} />
)} {/* Delete Confirmation Modal */} {showDeleteConfirm !== null && (

Delete Channel

{allChannels.length <= 1 ? (

Cannot delete the last remaining channel.

) : (

Are you sure you want to delete " {allChannels[showDeleteConfirm].name}"? {showDeleteConfirm < DEFAULT_CHANNELS.length - hiddenDefaultChannels.length && ' This will hide the default channel.'}

)}
)} setIsSettingsOpen(false)} currentTheme={currentTheme} setCurrentTheme={handleThemeChange} onResetDefaults={handleResetDefaults} />
) } export default EnhancedLofiPlayer