mirror of https://github.com/btahir/next-beats
				
				
				
			mvp
							parent
							
								
									efa9cf3d3f
								
							
						
					
					
						commit
						9cf1adcadb
					
				@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "semi": false,
 | 
			
		||||
  "singleQuote": true,
 | 
			
		||||
  "trailingComma": "es5",
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "printWidth": 80,
 | 
			
		||||
  "useTabs": false
 | 
			
		||||
}
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
@tailwind base;
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
:root {
 | 
			
		||||
  --background: #ffffff;
 | 
			
		||||
  --foreground: #171717;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: #0a0a0a;
 | 
			
		||||
    --foreground: #ededed;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  color: var(--foreground);
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  font-family: Arial, Helvetica, sans-serif;
 | 
			
		||||
}
 | 
			
		||||
@ -1,101 +1,847 @@
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
'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,
 | 
			
		||||
      }))
 | 
			
		||||
    )
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
 | 
			
		||||
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
 | 
			
		||||
        <Image
 | 
			
		||||
          className="dark:invert"
 | 
			
		||||
          src="/next.svg"
 | 
			
		||||
          alt="Next.js logo"
 | 
			
		||||
          width={180}
 | 
			
		||||
          height={38}
 | 
			
		||||
          priority
 | 
			
		||||
        />
 | 
			
		||||
        <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
 | 
			
		||||
          <li className="mb-2">
 | 
			
		||||
            Get started by editing{" "}
 | 
			
		||||
            <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
 | 
			
		||||
              app/page.tsx
 | 
			
		||||
            </code>
 | 
			
		||||
            .
 | 
			
		||||
          </li>
 | 
			
		||||
          <li>Save and see your changes instantly.</li>
 | 
			
		||||
        </ol>
 | 
			
		||||
 | 
			
		||||
        <div className="flex gap-4 items-center flex-col sm:flex-row">
 | 
			
		||||
          <a
 | 
			
		||||
            className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
 | 
			
		||||
            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
 | 
			
		||||
            target="_blank"
 | 
			
		||||
            rel="noopener noreferrer"
 | 
			
		||||
          >
 | 
			
		||||
            <Image
 | 
			
		||||
              className="dark:invert"
 | 
			
		||||
              src="/vercel.svg"
 | 
			
		||||
              alt="Vercel logomark"
 | 
			
		||||
              width={20}
 | 
			
		||||
              height={20}
 | 
			
		||||
            />
 | 
			
		||||
            Deploy now
 | 
			
		||||
          </a>
 | 
			
		||||
          <a
 | 
			
		||||
            className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
 | 
			
		||||
            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
 | 
			
		||||
            target="_blank"
 | 
			
		||||
            rel="noopener noreferrer"
 | 
			
		||||
          >
 | 
			
		||||
            Read our docs
 | 
			
		||||
          </a>
 | 
			
		||||
    <div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-screen">
 | 
			
		||||
      {staticPoints.map((point, i) => (
 | 
			
		||||
        <div key={i} className="absolute h-px w-px bg-white" style={point} />
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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<string>(
 | 
			
		||||
    'lofi-theme',
 | 
			
		||||
    'dark'
 | 
			
		||||
  )
 | 
			
		||||
  const [effectsVolume, setEffectsVolume] = useLocalStorage(
 | 
			
		||||
    'lofi-effects-volume',
 | 
			
		||||
    0.5
 | 
			
		||||
  )
 | 
			
		||||
  const [customChannels, setCustomChannels] = useLocalStorage<Channel[]>(
 | 
			
		||||
    '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<any>(null)
 | 
			
		||||
  const audioRefs = useRef<{ [key: string]: AudioCache }>({})
 | 
			
		||||
  const [activeEffects, setActiveEffects] = useState<Set<string>>(new Set())
 | 
			
		||||
  const [loadingEffects, setLoadingEffects] = useState<Set<string>>(new Set())
 | 
			
		||||
  const [isAddingChannel, setIsAddingChannel] = useState(false)
 | 
			
		||||
  const [newChannel, setNewChannel] = useState<Channel>({
 | 
			
		||||
    name: '',
 | 
			
		||||
    url: '',
 | 
			
		||||
    description: '',
 | 
			
		||||
    creator: '',
 | 
			
		||||
    isCustom: true,
 | 
			
		||||
  })
 | 
			
		||||
  const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | null>(
 | 
			
		||||
    null
 | 
			
		||||
  )
 | 
			
		||||
  const [isEditingChannel, setIsEditingChannel] = useState<number | null>(null)
 | 
			
		||||
  const [editingChannel, setEditingChannel] = useState<Channel>({
 | 
			
		||||
    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<void>((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<Channel[]>(() => {
 | 
			
		||||
    // 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 (
 | 
			
		||||
    <div
 | 
			
		||||
      className={styles['theme-container']}
 | 
			
		||||
      data-theme={mounted ? currentTheme : 'dark'}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="flex min-h-screen w-full items-start justify-center bg-[var(--lofi-background)] p-4 transition-colors duration-500 sm:items-center sm:p-8">
 | 
			
		||||
        <div className="w-full max-w-4xl space-y-8 py-4">
 | 
			
		||||
          {/* Retro TV */}
 | 
			
		||||
          <div className="shadow-[var(--lofi-accent)]/30 relative aspect-video overflow-hidden rounded-2xl border-4 border-[var(--lofi-border)] bg-black shadow-md transition-all duration-500">
 | 
			
		||||
            <div className="absolute inset-0">
 | 
			
		||||
              {mounted && <StaticEffect />}
 | 
			
		||||
              {mounted && (
 | 
			
		||||
                // @ts-ignore
 | 
			
		||||
                <ReactPlayer
 | 
			
		||||
                  ref={playerRef}
 | 
			
		||||
                  url={allChannels[currentChannel]?.url || ''}
 | 
			
		||||
                  playing={isPlaying}
 | 
			
		||||
                  volume={volume}
 | 
			
		||||
                  loop
 | 
			
		||||
                  width="100%"
 | 
			
		||||
                  height="100%"
 | 
			
		||||
                  onProgress={handleProgress}
 | 
			
		||||
                  onError={(error: Error) =>
 | 
			
		||||
                    console.error('Player error:', error)
 | 
			
		||||
                  }
 | 
			
		||||
                  config={{
 | 
			
		||||
                    playerVars: {
 | 
			
		||||
                      controls: 0,
 | 
			
		||||
                      modestbranding: 1,
 | 
			
		||||
                      iv_load_policy: 3,
 | 
			
		||||
                      rel: 0,
 | 
			
		||||
                      showinfo: 0,
 | 
			
		||||
                    },
 | 
			
		||||
                  }}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              <div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/70 to-transparent p-4">
 | 
			
		||||
                <div className="flex items-center justify-between">
 | 
			
		||||
                  <div className="flex items-center space-x-2">
 | 
			
		||||
                    <div className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
 | 
			
		||||
                    <span className="font-mono text-xs text-red-400">LIVE</span>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <span className="font-mono text-xs text-white/80">
 | 
			
		||||
                    CH{currentChannel + 1}
 | 
			
		||||
                  </span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="animate-scan absolute bottom-0 left-0 right-0 h-px bg-white/10" />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {/* Main Controls Section */}
 | 
			
		||||
          <div className="space-y-6 rounded-xl bg-[var(--lofi-card)] p-4 transition-colors duration-500 sm:p-6">
 | 
			
		||||
            {/* Channel Information */}
 | 
			
		||||
            <div className="relative space-y-1 px-2 font-mono text-[var(--lofi-text-primary)]">
 | 
			
		||||
              {/* Settings button */}
 | 
			
		||||
              <div className="absolute top-0 right-0 flex justify-center">
 | 
			
		||||
                <button
 | 
			
		||||
                  onClick={() => setIsSettingsOpen(true)}
 | 
			
		||||
                  className="rounded-full bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] transition-colors hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
                >
 | 
			
		||||
                  <Settings size={18} />
 | 
			
		||||
                </button>
 | 
			
		||||
              </div>
 | 
			
		||||
              {mounted ? (
 | 
			
		||||
                <>
 | 
			
		||||
                  <h2 className="text-xl font-bold">
 | 
			
		||||
                    {allChannels[currentChannel].name}
 | 
			
		||||
                  </h2>
 | 
			
		||||
                  <p className="text-sm text-[var(--lofi-text-secondary)]">
 | 
			
		||||
                    {allChannels[currentChannel].description}
 | 
			
		||||
                  </p>
 | 
			
		||||
                  <p className="text-sm text-[var(--lofi-accent)]">
 | 
			
		||||
                    by {allChannels[currentChannel].creator}
 | 
			
		||||
                  </p>
 | 
			
		||||
                </>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <>
 | 
			
		||||
                  <h2 className="text-xl font-bold">
 | 
			
		||||
                    {DEFAULT_CHANNELS[0].name}
 | 
			
		||||
                  </h2>
 | 
			
		||||
                  <p className="text-sm text-[var(--lofi-text-secondary)]">
 | 
			
		||||
                    {DEFAULT_CHANNELS[0].description}
 | 
			
		||||
                  </p>
 | 
			
		||||
                  <p className="text-sm text-purple-400">
 | 
			
		||||
                    by {DEFAULT_CHANNELS[0].creator}
 | 
			
		||||
                  </p>
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {/* Channel Buttons */}
 | 
			
		||||
            {mounted && (
 | 
			
		||||
              <div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
 | 
			
		||||
                <ChannelButtons
 | 
			
		||||
                  channels={allChannels}
 | 
			
		||||
                  currentChannel={currentChannel}
 | 
			
		||||
                  setCurrentChannel={setCurrentChannel}
 | 
			
		||||
                  currentTheme={currentTheme}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            {/* Controls Container */}
 | 
			
		||||
            <div className="space-y-4">
 | 
			
		||||
              <div className="flex items-center justify-between px-2">
 | 
			
		||||
                {/* Left Side - Playback Controls */}
 | 
			
		||||
                {mounted && (
 | 
			
		||||
                  <PlaybackControls
 | 
			
		||||
                    isPlaying={isPlaying}
 | 
			
		||||
                    setIsPlaying={setIsPlaying}
 | 
			
		||||
                    volume={volume}
 | 
			
		||||
                    setVolume={handleVolumeChange}
 | 
			
		||||
                    changeChannel={changeChannel}
 | 
			
		||||
                  />
 | 
			
		||||
                )}
 | 
			
		||||
 | 
			
		||||
                {/* Right Side - Channel Management */}
 | 
			
		||||
                {mounted && (
 | 
			
		||||
                  <ChannelManagement
 | 
			
		||||
                    isAddingChannel={isAddingChannel}
 | 
			
		||||
                    setIsAddingChannel={setIsAddingChannel}
 | 
			
		||||
                    newChannel={newChannel}
 | 
			
		||||
                    setNewChannel={setNewChannel}
 | 
			
		||||
                    saveChannel={handleSaveChannel}
 | 
			
		||||
                    currentTheme={currentTheme}
 | 
			
		||||
                    currentChannel={currentChannel}
 | 
			
		||||
                    handleEditChannel={handleEditChannel}
 | 
			
		||||
                    setShowDeleteConfirm={setShowDeleteConfirm}
 | 
			
		||||
                  />
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              {/* Progress Bar */}
 | 
			
		||||
              <div className="px-2">
 | 
			
		||||
                <div className="h-1 w-full overflow-hidden rounded-full bg-[var(--lofi-card-hover)]">
 | 
			
		||||
                  <div
 | 
			
		||||
                    className="h-full bg-[var(--lofi-accent)] transition-all duration-300"
 | 
			
		||||
                    style={{ width: `${played * 100}%` }}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {/* Sound Effects Section - Separated into its own card */}
 | 
			
		||||
          {mounted && (
 | 
			
		||||
            <div className="rounded-xl bg-[var(--lofi-card)] p-4 transition-colors duration-500 sm:p-6">
 | 
			
		||||
              <SoundEffectsControls
 | 
			
		||||
                activeEffects={activeEffects}
 | 
			
		||||
                toggleEffect={toggleEffect}
 | 
			
		||||
                effectsVolume={effectsVolume}
 | 
			
		||||
                setEffectsVolume={handleEffectsVolumeChange}
 | 
			
		||||
                effectVolumes={effectVolumes}
 | 
			
		||||
                setEffectVolumes={setEffectVolumes}
 | 
			
		||||
                currentTheme={currentTheme}
 | 
			
		||||
                customEffects={customEffects}
 | 
			
		||||
                setCustomEffects={setCustomEffects}
 | 
			
		||||
                loadingEffects={loadingEffects}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </main>
 | 
			
		||||
      <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
 | 
			
		||||
        <a
 | 
			
		||||
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
 | 
			
		||||
          href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          <Image
 | 
			
		||||
            aria-hidden
 | 
			
		||||
            src="/file.svg"
 | 
			
		||||
            alt="File icon"
 | 
			
		||||
            width={16}
 | 
			
		||||
            height={16}
 | 
			
		||||
          />
 | 
			
		||||
          Learn
 | 
			
		||||
        </a>
 | 
			
		||||
        <a
 | 
			
		||||
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
 | 
			
		||||
          href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          <Image
 | 
			
		||||
            aria-hidden
 | 
			
		||||
            src="/window.svg"
 | 
			
		||||
            alt="Window icon"
 | 
			
		||||
            width={16}
 | 
			
		||||
            height={16}
 | 
			
		||||
          />
 | 
			
		||||
          Examples
 | 
			
		||||
        </a>
 | 
			
		||||
        <a
 | 
			
		||||
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
 | 
			
		||||
          href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          rel="noopener noreferrer"
 | 
			
		||||
        >
 | 
			
		||||
          <Image
 | 
			
		||||
            aria-hidden
 | 
			
		||||
            src="/globe.svg"
 | 
			
		||||
            alt="Globe icon"
 | 
			
		||||
            width={16}
 | 
			
		||||
            height={16}
 | 
			
		||||
          />
 | 
			
		||||
          Go to nextjs.org →
 | 
			
		||||
        </a>
 | 
			
		||||
      </footer>
 | 
			
		||||
 | 
			
		||||
        {/* Channel Edit Modal */}
 | 
			
		||||
        {isEditingChannel !== null && (
 | 
			
		||||
          <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
 | 
			
		||||
            <div
 | 
			
		||||
              className={`w-full max-w-md rounded-lg bg-[var(--lofi-card)] p-6`}
 | 
			
		||||
            >
 | 
			
		||||
              <h3
 | 
			
		||||
                className={`mb-4 text-lg font-bold text-[var(--lofi-text-primary)]`}
 | 
			
		||||
              >
 | 
			
		||||
                Edit Channel {isEditingChannel + 1}
 | 
			
		||||
              </h3>
 | 
			
		||||
              <div className="space-y-3">
 | 
			
		||||
                <input
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  placeholder="Channel Name"
 | 
			
		||||
                  value={editingChannel.name}
 | 
			
		||||
                  onChange={(e) =>
 | 
			
		||||
                    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)]`}
 | 
			
		||||
                />
 | 
			
		||||
                <input
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  placeholder="YouTube URL"
 | 
			
		||||
                  value={editingChannel.url}
 | 
			
		||||
                  onChange={(e) =>
 | 
			
		||||
                    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)]`}
 | 
			
		||||
                />
 | 
			
		||||
                <input
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  placeholder="Description"
 | 
			
		||||
                  value={editingChannel.description}
 | 
			
		||||
                  onChange={(e) =>
 | 
			
		||||
                    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)]`}
 | 
			
		||||
                />
 | 
			
		||||
                <input
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  placeholder="Creator"
 | 
			
		||||
                  value={editingChannel.creator}
 | 
			
		||||
                  onChange={(e) =>
 | 
			
		||||
                    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)]`}
 | 
			
		||||
                />
 | 
			
		||||
                <div className="flex justify-end space-x-2">
 | 
			
		||||
                  <button
 | 
			
		||||
                    onClick={() => setIsEditingChannel(null)}
 | 
			
		||||
                    className={`rounded-full px-3 py-1 text-xs text-[var(--lofi-text-primary)] hover:text-[var(--lofi-text-secondary)]`}
 | 
			
		||||
                  >
 | 
			
		||||
                    Cancel
 | 
			
		||||
                  </button>
 | 
			
		||||
                  <button
 | 
			
		||||
                    onClick={handleSaveEditedChannel}
 | 
			
		||||
                    className="flex items-center space-x-2 rounded-full bg-[var(--lofi-button-bg)] px-3 py-1 text-xs text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Save size={14} />
 | 
			
		||||
                    <span>Save Changes</span>
 | 
			
		||||
                  </button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {/* Delete Confirmation Modal */}
 | 
			
		||||
        {showDeleteConfirm !== null && (
 | 
			
		||||
          <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
 | 
			
		||||
            <div
 | 
			
		||||
              className={`w-full max-w-sm rounded-lg bg-[var(--lofi-card)] p-6`}
 | 
			
		||||
            >
 | 
			
		||||
              <h3
 | 
			
		||||
                className={`mb-4 text-lg font-bold text-[var(--lofi-text-primary)]`}
 | 
			
		||||
              >
 | 
			
		||||
                Delete Channel
 | 
			
		||||
              </h3>
 | 
			
		||||
              {allChannels.length <= 1 ? (
 | 
			
		||||
                <p className="mb-4 text-sm text-[var(--lofi-text-secondary)]">
 | 
			
		||||
                  Cannot delete the last remaining channel.
 | 
			
		||||
                </p>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <p className={`mb-4 text-sm text-[var(--lofi-text-secondary)]`}>
 | 
			
		||||
                  Are you sure you want to delete "
 | 
			
		||||
                  {allChannels[showDeleteConfirm].name}"?
 | 
			
		||||
                  {showDeleteConfirm <
 | 
			
		||||
                    DEFAULT_CHANNELS.length - hiddenDefaultChannels.length &&
 | 
			
		||||
                    ' This will hide the default channel.'}
 | 
			
		||||
                </p>
 | 
			
		||||
              )}
 | 
			
		||||
              <div className="flex justify-end space-x-2">
 | 
			
		||||
                <button
 | 
			
		||||
                  onClick={() => setShowDeleteConfirm(null)}
 | 
			
		||||
                  className={`rounded-full px-3 py-1 text-xs text-[var(--lofi-text-primary)] hover:text-[var(--lofi-text-secondary)]`}
 | 
			
		||||
                >
 | 
			
		||||
                  Cancel
 | 
			
		||||
                </button>
 | 
			
		||||
                <button
 | 
			
		||||
                  onClick={() => handleDeleteChannel(showDeleteConfirm)}
 | 
			
		||||
                  className={`flex items-center space-x-2 rounded-full ${
 | 
			
		||||
                    allChannels.length <= 1
 | 
			
		||||
                      ? 'cursor-not-allowed bg-[var(--lofi-button-bg)]'
 | 
			
		||||
                      : 'bg-[var(--lofi-button-bg)] hover:bg-[var(--lofi-button-hover)]'
 | 
			
		||||
                  } px-3 py-1 text-xs text-[var(--lofi-button-text)]`}
 | 
			
		||||
                  disabled={allChannels.length <= 1}
 | 
			
		||||
                >
 | 
			
		||||
                  <X size={14} />
 | 
			
		||||
                  <span>Delete Channel</span>
 | 
			
		||||
                </button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <SettingsModal
 | 
			
		||||
          isOpen={isSettingsOpen}
 | 
			
		||||
          onClose={() => setIsSettingsOpen(false)}
 | 
			
		||||
          currentTheme={currentTheme}
 | 
			
		||||
          setCurrentTheme={handleThemeChange}
 | 
			
		||||
          onResetDefaults={handleResetDefaults}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default EnhancedLofiPlayer
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,21 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://ui.shadcn.com/schema.json",
 | 
			
		||||
  "style": "new-york",
 | 
			
		||||
  "rsc": true,
 | 
			
		||||
  "tsx": true,
 | 
			
		||||
  "tailwind": {
 | 
			
		||||
    "config": "tailwind.config.ts",
 | 
			
		||||
    "css": "app/globals.css",
 | 
			
		||||
    "baseColor": "neutral",
 | 
			
		||||
    "cssVariables": true,
 | 
			
		||||
    "prefix": ""
 | 
			
		||||
  },
 | 
			
		||||
  "aliases": {
 | 
			
		||||
    "components": "@/components",
 | 
			
		||||
    "utils": "@/lib/utils",
 | 
			
		||||
    "ui": "@/components/ui",
 | 
			
		||||
    "lib": "@/lib",
 | 
			
		||||
    "hooks": "@/hooks"
 | 
			
		||||
  },
 | 
			
		||||
  "iconLibrary": "lucide"
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,34 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import { Channel } from '@/types/lofi'
 | 
			
		||||
 | 
			
		||||
interface ChannelButtonsProps {
 | 
			
		||||
  channels: Channel[]
 | 
			
		||||
  currentChannel: number
 | 
			
		||||
  setCurrentChannel: (index: number) => void
 | 
			
		||||
  currentTheme: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ChannelButtons: React.FC<ChannelButtonsProps> = ({
 | 
			
		||||
  channels,
 | 
			
		||||
  currentChannel,
 | 
			
		||||
  setCurrentChannel,
 | 
			
		||||
  currentTheme,
 | 
			
		||||
}) => (
 | 
			
		||||
  <div className="flex space-x-2 overflow-x-auto pb-2">
 | 
			
		||||
    {channels.map((channel, idx) => (
 | 
			
		||||
      <button
 | 
			
		||||
        key={idx}
 | 
			
		||||
        onClick={() => setCurrentChannel(idx)}
 | 
			
		||||
        className={`flex-shrink-0 rounded-[var(--lofi-button-radius)] px-3 py-1 font-mono text-xs shadow-[var(--lofi-card-shadow)] transition-colors ${
 | 
			
		||||
          currentChannel === idx
 | 
			
		||||
            ? 'bg-[var(--lofi-accent)] text-white hover:bg-[var(--lofi-accent-hover)]'
 | 
			
		||||
            : 'bg-[var(--lofi-button-bg)] text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]'
 | 
			
		||||
        }`}
 | 
			
		||||
      >
 | 
			
		||||
        CH{idx + 1} {channel.isCustom && '★'}
 | 
			
		||||
      </button>
 | 
			
		||||
    ))}
 | 
			
		||||
  </div>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
export default ChannelButtons
 | 
			
		||||
@ -0,0 +1,119 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import { Edit2, X, Plus, Save } from 'lucide-react'
 | 
			
		||||
import { Channel } from '@/types/lofi'
 | 
			
		||||
 | 
			
		||||
interface ChannelManagementProps {
 | 
			
		||||
  isAddingChannel: boolean
 | 
			
		||||
  setIsAddingChannel: (adding: boolean) => void
 | 
			
		||||
  newChannel: Channel
 | 
			
		||||
  setNewChannel: (channel: Channel) => void
 | 
			
		||||
  saveChannel: () => void
 | 
			
		||||
  currentTheme: string
 | 
			
		||||
  currentChannel: number
 | 
			
		||||
  handleEditChannel: (index: number) => void
 | 
			
		||||
  setShowDeleteConfirm: (channelIndex: number) => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ChannelManagement: React.FC<ChannelManagementProps> = ({
 | 
			
		||||
  isAddingChannel,
 | 
			
		||||
  setIsAddingChannel,
 | 
			
		||||
  newChannel,
 | 
			
		||||
  setNewChannel,
 | 
			
		||||
  saveChannel,
 | 
			
		||||
  currentTheme,
 | 
			
		||||
  currentChannel,
 | 
			
		||||
  handleEditChannel,
 | 
			
		||||
  setShowDeleteConfirm,
 | 
			
		||||
}) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={
 | 
			
		||||
      isAddingChannel
 | 
			
		||||
        ? 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4'
 | 
			
		||||
        : 'flex items-center space-x-2'
 | 
			
		||||
    }
 | 
			
		||||
  >
 | 
			
		||||
    {!isAddingChannel ? (
 | 
			
		||||
      <div className="flex items-center space-x-2">
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={() => handleEditChannel(currentChannel)}
 | 
			
		||||
          className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
        >
 | 
			
		||||
          <Edit2 size={16} />
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={() => setShowDeleteConfirm(currentChannel)}
 | 
			
		||||
          className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
        >
 | 
			
		||||
          <X size={16} />
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={() => setIsAddingChannel(true)}
 | 
			
		||||
          className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
        >
 | 
			
		||||
          <Plus size={16} />
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    ) : (
 | 
			
		||||
      <div className="w-full max-w-md space-y-3 rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
 | 
			
		||||
        <h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
 | 
			
		||||
          Add New Channel
 | 
			
		||||
        </h3>
 | 
			
		||||
        <div className="grid grid-cols-1 gap-3">
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="Channel Name"
 | 
			
		||||
            value={newChannel.name}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
              setNewChannel({ ...newChannel, name: e.target.value })
 | 
			
		||||
            }
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]"
 | 
			
		||||
          />
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="YouTube URL"
 | 
			
		||||
            value={newChannel.url}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
              setNewChannel({ ...newChannel, url: e.target.value })
 | 
			
		||||
            }
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]"
 | 
			
		||||
          />
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="Description"
 | 
			
		||||
            value={newChannel.description}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
              setNewChannel({ ...newChannel, description: e.target.value })
 | 
			
		||||
            }
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]"
 | 
			
		||||
          />
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            placeholder="Creator"
 | 
			
		||||
            value={newChannel.creator}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
              setNewChannel({ ...newChannel, creator: e.target.value })
 | 
			
		||||
            }
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="flex justify-end space-x-2">
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={() => setIsAddingChannel(false)}
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] px-3 py-1 text-xs text-[var(--lofi-text-secondary)] hover:text-[var(--lofi-text-primary)]"
 | 
			
		||||
          >
 | 
			
		||||
            Cancel
 | 
			
		||||
          </button>
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={saveChannel}
 | 
			
		||||
            className="flex items-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-accent)] px-3 py-1 text-xs text-white shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-accent-hover)]"
 | 
			
		||||
          >
 | 
			
		||||
            <Save size={14} />
 | 
			
		||||
            <span>Save Channel</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    )}
 | 
			
		||||
  </div>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
export default ChannelManagement
 | 
			
		||||
@ -0,0 +1,77 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import {
 | 
			
		||||
  SkipBack,
 | 
			
		||||
  Play,
 | 
			
		||||
  Pause,
 | 
			
		||||
  SkipForward,
 | 
			
		||||
  VolumeX,
 | 
			
		||||
  Volume1,
 | 
			
		||||
  Volume2,
 | 
			
		||||
} from 'lucide-react'
 | 
			
		||||
 | 
			
		||||
interface PlaybackControlsProps {
 | 
			
		||||
  isPlaying: boolean
 | 
			
		||||
  setIsPlaying: (playing: boolean) => void
 | 
			
		||||
  volume: number
 | 
			
		||||
  setVolume: (vol: number) => void
 | 
			
		||||
  changeChannel: (direction: 'next' | 'prev') => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const PlaybackControls: React.FC<PlaybackControlsProps> = ({
 | 
			
		||||
  isPlaying,
 | 
			
		||||
  setIsPlaying,
 | 
			
		||||
  volume,
 | 
			
		||||
  setVolume,
 | 
			
		||||
  changeChannel,
 | 
			
		||||
}) => (
 | 
			
		||||
  <div className="flex items-center justify-between">
 | 
			
		||||
    {/* Left Side - Playback Controls */}
 | 
			
		||||
    <div className="flex items-center space-x-2">
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={() => changeChannel('prev')}
 | 
			
		||||
        className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
 | 
			
		||||
      >
 | 
			
		||||
        <SkipBack size={16} />
 | 
			
		||||
      </button>
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={() => setIsPlaying(!isPlaying)}
 | 
			
		||||
        className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
 | 
			
		||||
      >
 | 
			
		||||
        {isPlaying ? <Pause size={16} /> : <Play size={16} />}
 | 
			
		||||
      </button>
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={() => changeChannel('next')}
 | 
			
		||||
        className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
 | 
			
		||||
      >
 | 
			
		||||
        <SkipForward size={16} />
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    {/* Right Side - Volume Control */}
 | 
			
		||||
    <div className="flex items-center space-x-2">
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
 | 
			
		||||
        className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
 | 
			
		||||
      >
 | 
			
		||||
        {volume === 0 ? (
 | 
			
		||||
          <VolumeX size={16} />
 | 
			
		||||
        ) : volume < 0.5 ? (
 | 
			
		||||
          <Volume1 size={16} />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Volume2 size={16} />
 | 
			
		||||
        )}
 | 
			
		||||
      </button>
 | 
			
		||||
      <input
 | 
			
		||||
        type="range"
 | 
			
		||||
        min={0}
 | 
			
		||||
        max={1}
 | 
			
		||||
        step={0.01}
 | 
			
		||||
        value={volume}
 | 
			
		||||
        onChange={(e) => setVolume(parseFloat(e.target.value))}
 | 
			
		||||
        className="w-16 accent-[var(--lofi-accent)]"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
export default PlaybackControls
 | 
			
		||||
@ -0,0 +1,92 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import { X, RotateCcw } from 'lucide-react'
 | 
			
		||||
import { themes } from '@/lib/lofi-themes'
 | 
			
		||||
 | 
			
		||||
interface SettingsModalProps {
 | 
			
		||||
  isOpen: boolean
 | 
			
		||||
  onClose: () => void
 | 
			
		||||
  currentTheme: string
 | 
			
		||||
  setCurrentTheme: (themeId: string) => void
 | 
			
		||||
  onResetDefaults: () => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SettingsModal: React.FC<SettingsModalProps> = ({
 | 
			
		||||
  isOpen,
 | 
			
		||||
  onClose,
 | 
			
		||||
  currentTheme,
 | 
			
		||||
  setCurrentTheme,
 | 
			
		||||
  onResetDefaults,
 | 
			
		||||
}) => {
 | 
			
		||||
  const handleResetDefaults = () => {
 | 
			
		||||
    if (
 | 
			
		||||
      window.confirm(
 | 
			
		||||
        'This will reset all settings to their defaults. Continue?'
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      localStorage.removeItem('lofi-volume')
 | 
			
		||||
      localStorage.removeItem('lofi-theme')
 | 
			
		||||
      localStorage.removeItem('lofi-effects-volume')
 | 
			
		||||
      localStorage.removeItem('lofi-effect-volumes')
 | 
			
		||||
      localStorage.removeItem('customChannels')
 | 
			
		||||
      localStorage.removeItem('hiddenDefaultChannels')
 | 
			
		||||
      localStorage.removeItem('customSoundEffects')
 | 
			
		||||
 | 
			
		||||
      window.location.reload()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!isOpen) return null
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
 | 
			
		||||
      <div className="w-full max-w-md rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
 | 
			
		||||
        <div className="mb-6 flex items-center justify-between">
 | 
			
		||||
          <h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
 | 
			
		||||
            Settings
 | 
			
		||||
          </h3>
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={onClose}
 | 
			
		||||
            className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-secondary)] hover:bg-[var(--lofi-card-hover)]"
 | 
			
		||||
          >
 | 
			
		||||
            <X size={20} />
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="space-y-6">
 | 
			
		||||
          <div className="space-y-3">
 | 
			
		||||
            <label
 | 
			
		||||
              htmlFor="theme-select"
 | 
			
		||||
              className="block text-sm font-medium text-[var(--lofi-text-primary)]"
 | 
			
		||||
            >
 | 
			
		||||
              Theme
 | 
			
		||||
            </label>
 | 
			
		||||
            <select
 | 
			
		||||
              id="theme-select"
 | 
			
		||||
              value={currentTheme}
 | 
			
		||||
              onChange={(e) => setCurrentTheme(e.target.value)}
 | 
			
		||||
              className="w-full rounded-[var(--lofi-button-radius)] border border-[var(--lofi-border)] bg-[var(--lofi-card-hover)] px-3 py-2 text-[var(--lofi-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--lofi-accent)]"
 | 
			
		||||
            >
 | 
			
		||||
              {Object.values(themes).map((theme) => (
 | 
			
		||||
                <option key={theme.id} value={theme.id}>
 | 
			
		||||
                  {theme.name}
 | 
			
		||||
                </option>
 | 
			
		||||
              ))}
 | 
			
		||||
            </select>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div className="pt-4">
 | 
			
		||||
            <button
 | 
			
		||||
              onClick={handleResetDefaults}
 | 
			
		||||
              className="flex w-full items-center justify-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] py-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
 | 
			
		||||
            >
 | 
			
		||||
              <RotateCcw size={16} />
 | 
			
		||||
              <span>Restore Defaults</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default SettingsModal
 | 
			
		||||
@ -0,0 +1,301 @@
 | 
			
		||||
import React, { useState, useEffect } from 'react'
 | 
			
		||||
import { Plus, Music, X } from 'lucide-react'
 | 
			
		||||
import { soundEffects } from '@/lib/lofi_data'
 | 
			
		||||
import { SoundEffect, CustomSoundEffect } from '@/types/lofi'
 | 
			
		||||
import dynamic from 'next/dynamic'
 | 
			
		||||
 | 
			
		||||
const ReactPlayer = dynamic(() => import('react-player/youtube'), {
 | 
			
		||||
  ssr: false,
 | 
			
		||||
}) as any
 | 
			
		||||
 | 
			
		||||
interface SoundEffectsControlsProps {
 | 
			
		||||
  activeEffects: Set<string>
 | 
			
		||||
  toggleEffect: (effectId: string) => void
 | 
			
		||||
  effectsVolume: number
 | 
			
		||||
  setEffectsVolume: (vol: number) => void
 | 
			
		||||
  effectVolumes: { [key: string]: number }
 | 
			
		||||
  setEffectVolumes: (volumes: { [key: string]: number }) => void
 | 
			
		||||
  currentTheme: string
 | 
			
		||||
  customEffects: CustomSoundEffect[]
 | 
			
		||||
  setCustomEffects: (effects: CustomSoundEffect[]) => void
 | 
			
		||||
  loadingEffects: Set<string>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SoundEffectsControls: React.FC<SoundEffectsControlsProps> = ({
 | 
			
		||||
  activeEffects,
 | 
			
		||||
  toggleEffect,
 | 
			
		||||
  effectsVolume,
 | 
			
		||||
  setEffectsVolume,
 | 
			
		||||
  effectVolumes,
 | 
			
		||||
  setEffectVolumes,
 | 
			
		||||
  currentTheme,
 | 
			
		||||
  customEffects,
 | 
			
		||||
  setCustomEffects,
 | 
			
		||||
  loadingEffects,
 | 
			
		||||
}) => {
 | 
			
		||||
  const [isAddingEffect, setIsAddingEffect] = useState(false)
 | 
			
		||||
  const [newEffect, setNewEffect] = useState<CustomSoundEffect>({
 | 
			
		||||
    id: '',
 | 
			
		||||
    name: '',
 | 
			
		||||
    file: '',
 | 
			
		||||
    isYoutube: true,
 | 
			
		||||
  })
 | 
			
		||||
  const [urlError, setUrlError] = useState('')
 | 
			
		||||
 | 
			
		||||
  const allEffects = [
 | 
			
		||||
    ...soundEffects.map((effect) => ({
 | 
			
		||||
      ...effect,
 | 
			
		||||
      isYoutube: false,
 | 
			
		||||
    })),
 | 
			
		||||
    ...customEffects.map((effect) => ({
 | 
			
		||||
      ...effect,
 | 
			
		||||
      icon: Music,
 | 
			
		||||
      isCustom: true,
 | 
			
		||||
    })),
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  const validateYoutubeUrl = (url: string): boolean => {
 | 
			
		||||
    try {
 | 
			
		||||
      const urlObj = new URL(url)
 | 
			
		||||
      return (
 | 
			
		||||
        urlObj.hostname === 'www.youtube.com' ||
 | 
			
		||||
        urlObj.hostname === 'youtube.com' ||
 | 
			
		||||
        urlObj.hostname === 'youtu.be'
 | 
			
		||||
      )
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleAddEffect = async () => {
 | 
			
		||||
    if (!newEffect.name || !newEffect.file) {
 | 
			
		||||
      alert('Please provide both name and YouTube URL')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!validateYoutubeUrl(newEffect.file)) {
 | 
			
		||||
      alert('Please provide a valid YouTube URL')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const effectId = `custom_${Date.now()}`
 | 
			
		||||
    const newCustomEffect: CustomSoundEffect = {
 | 
			
		||||
      id: effectId,
 | 
			
		||||
      name: newEffect.name,
 | 
			
		||||
      file: newEffect.file,
 | 
			
		||||
      isYoutube: true,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setCustomEffects([...customEffects, newCustomEffect])
 | 
			
		||||
    const defaultVolume = 0.5
 | 
			
		||||
    setEffectVolumes({
 | 
			
		||||
      ...effectVolumes,
 | 
			
		||||
      [effectId]: defaultVolume,
 | 
			
		||||
    })
 | 
			
		||||
    setIsAddingEffect(false)
 | 
			
		||||
    setNewEffect({ id: '', name: '', file: '', isYoutube: true })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleDeleteEffect = (effectId: string) => {
 | 
			
		||||
    setCustomEffects(customEffects.filter((effect) => effect.id !== effectId))
 | 
			
		||||
    const newVolumes = { ...effectVolumes }
 | 
			
		||||
    delete newVolumes[effectId]
 | 
			
		||||
    setEffectVolumes(newVolumes)
 | 
			
		||||
    if (activeEffects.has(effectId)) {
 | 
			
		||||
      toggleEffect(effectId)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const renderSoundEffect = (effect: SoundEffect) => {
 | 
			
		||||
    const isActive = activeEffects.has(effect.id)
 | 
			
		||||
    const isLoading = loadingEffects.has(effect.id)
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        key={effect.id}
 | 
			
		||||
        className={`relative flex flex-col rounded-[var(--lofi-card-radius)] p-3 shadow-[var(--lofi-card-shadow)] transition-colors ${
 | 
			
		||||
          isActive
 | 
			
		||||
            ? 'bg-[var(--lofi-card)] ring-1 ring-[var(--lofi-accent)] ring-opacity-50'
 | 
			
		||||
            : 'bg-[var(--lofi-card-hover)]'
 | 
			
		||||
        }`}
 | 
			
		||||
      >
 | 
			
		||||
        {effect.isYoutube && isActive && (
 | 
			
		||||
          <div className="hidden">
 | 
			
		||||
            <ReactPlayer
 | 
			
		||||
              url={effect.file}
 | 
			
		||||
              playing={isActive}
 | 
			
		||||
              volume={effectVolumes[effect.id] * effectsVolume}
 | 
			
		||||
              loop
 | 
			
		||||
              config={{
 | 
			
		||||
                youtube: {
 | 
			
		||||
                  playerVars: { controls: 0 },
 | 
			
		||||
                },
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <div className="mb-2 flex items-center justify-between">
 | 
			
		||||
          <div className="flex items-center space-x-2">
 | 
			
		||||
            <button
 | 
			
		||||
              onClick={() => toggleEffect(effect.id)}
 | 
			
		||||
              disabled={isLoading}
 | 
			
		||||
              className={`rounded-[var(--lofi-button-radius)] p-1.5 shadow-[var(--lofi-card-shadow)] transition-colors focus:outline-none ${
 | 
			
		||||
                isLoading
 | 
			
		||||
                  ? 'opacity-50'
 | 
			
		||||
                  : isActive
 | 
			
		||||
                  ? 'bg-[var(--lofi-accent)] text-white'
 | 
			
		||||
                  : 'bg-[var(--lofi-button-bg)] text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]'
 | 
			
		||||
              }`}
 | 
			
		||||
            >
 | 
			
		||||
              {isLoading ? (
 | 
			
		||||
                <div className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-transparent" />
 | 
			
		||||
              ) : (
 | 
			
		||||
                <effect.icon size={16} />
 | 
			
		||||
              )}
 | 
			
		||||
            </button>
 | 
			
		||||
            <span className="font-mono text-xs text-[var(--lofi-text-primary)]">
 | 
			
		||||
              {effect.name}
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex items-center space-x-2">
 | 
			
		||||
            <span className="font-mono text-xs text-[var(--lofi-text-secondary)]">
 | 
			
		||||
              {Math.round(effectVolumes[effect.id] * 100)}%
 | 
			
		||||
            </span>
 | 
			
		||||
            {effect.isCustom && (
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={() => handleDeleteEffect(effect.id)}
 | 
			
		||||
                className="rounded-md bg-[var(--lofi-button-bg)] p-1 text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)] focus:outline-none"
 | 
			
		||||
              >
 | 
			
		||||
                <X size={14} />
 | 
			
		||||
              </button>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <input
 | 
			
		||||
          type="range"
 | 
			
		||||
          min={0}
 | 
			
		||||
          max={1}
 | 
			
		||||
          step={0.01}
 | 
			
		||||
          value={effectVolumes[effect.id]}
 | 
			
		||||
          onChange={(e) =>
 | 
			
		||||
            setEffectVolumes({
 | 
			
		||||
              ...effectVolumes,
 | 
			
		||||
              [effect.id]: parseFloat(e.target.value),
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
          className="w-full focus:outline-none [&::-moz-range-thumb]:bg-[var(--lofi-accent)] [&::-webkit-slider-thumb]:bg-[var(--lofi-accent)]"
 | 
			
		||||
          style={{
 | 
			
		||||
            accentColor: 'var(--lofi-accent)',
 | 
			
		||||
          }}
 | 
			
		||||
          disabled={!isActive}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const defaultVolume = 0.5
 | 
			
		||||
    const newVolumes = { ...effectVolumes }
 | 
			
		||||
    let hasChanges = false
 | 
			
		||||
 | 
			
		||||
    allEffects.forEach((effect) => {
 | 
			
		||||
      if (effectVolumes[effect.id] === undefined) {
 | 
			
		||||
        newVolumes[effect.id] = defaultVolume
 | 
			
		||||
        hasChanges = true
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (hasChanges) {
 | 
			
		||||
      setEffectVolumes(newVolumes)
 | 
			
		||||
    }
 | 
			
		||||
  }, [allEffects, effectVolumes])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="space-y-4">
 | 
			
		||||
      <div className="mb-4 flex flex-row justify-between gap-4 sm:items-center">
 | 
			
		||||
        <h3 className="mt-2 font-mono text-sm text-[var(--lofi-text-primary)]">
 | 
			
		||||
          Effects
 | 
			
		||||
        </h3>
 | 
			
		||||
        <div className="flex items-center space-x-2">
 | 
			
		||||
          <button
 | 
			
		||||
            onClick={() => setIsAddingEffect(true)}
 | 
			
		||||
            className="rounded-full bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)] focus:outline-none"
 | 
			
		||||
          >
 | 
			
		||||
            <Plus size={16} />
 | 
			
		||||
          </button>
 | 
			
		||||
          <span className="hidden font-mono text-xs text-[var(--lofi-text-secondary)] sm:inline">
 | 
			
		||||
            Master Volume
 | 
			
		||||
          </span>
 | 
			
		||||
          <input
 | 
			
		||||
            type="range"
 | 
			
		||||
            min={0}
 | 
			
		||||
            max={1}
 | 
			
		||||
            step={0.01}
 | 
			
		||||
            value={effectsVolume}
 | 
			
		||||
            onChange={(e) => setEffectsVolume(parseFloat(e.target.value))}
 | 
			
		||||
            className="w-32 focus:outline-none sm:w-20 [&::-moz-range-thumb]:bg-[var(--lofi-accent)] [&::-webkit-slider-thumb]:bg-[var(--lofi-accent)]"
 | 
			
		||||
            style={{
 | 
			
		||||
              accentColor: 'var(--lofi-accent)',
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
 | 
			
		||||
        {allEffects.map((effect) => renderSoundEffect(effect))}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {isAddingEffect && (
 | 
			
		||||
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
 | 
			
		||||
          <div className="w-full max-w-md space-y-3 rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
 | 
			
		||||
            <h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
 | 
			
		||||
              Add Sound Effect
 | 
			
		||||
            </h3>
 | 
			
		||||
            <input
 | 
			
		||||
              type="text"
 | 
			
		||||
              placeholder="Effect Name"
 | 
			
		||||
              value={newEffect.name}
 | 
			
		||||
              onChange={(e) =>
 | 
			
		||||
                setNewEffect({ ...newEffect, name: e.target.value })
 | 
			
		||||
              }
 | 
			
		||||
              className="w-full rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)] focus:outline-none"
 | 
			
		||||
            />
 | 
			
		||||
            <div className="space-y-1">
 | 
			
		||||
              <input
 | 
			
		||||
                type="url"
 | 
			
		||||
                placeholder="YouTube URL"
 | 
			
		||||
                value={newEffect.file}
 | 
			
		||||
                onChange={(e) => {
 | 
			
		||||
                  setNewEffect({ ...newEffect, file: e.target.value })
 | 
			
		||||
                  setUrlError('')
 | 
			
		||||
                }}
 | 
			
		||||
                className={`w-full rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)] focus:outline-none ${
 | 
			
		||||
                  urlError ? 'border border-red-500' : ''
 | 
			
		||||
                }`}
 | 
			
		||||
              />
 | 
			
		||||
              {urlError && <p className="text-xs text-red-500">{urlError}</p>}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex justify-end space-x-2">
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={() => setIsAddingEffect(false)}
 | 
			
		||||
                className="rounded-[var(--lofi-button-radius)] px-3 py-1 text-xs text-[var(--lofi-text-secondary)] hover:text-[var(--lofi-text-primary)] focus:outline-none"
 | 
			
		||||
              >
 | 
			
		||||
                Cancel
 | 
			
		||||
              </button>
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={handleAddEffect}
 | 
			
		||||
                className="flex items-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-accent)] px-3 py-1 text-xs text-white shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-accent-hover)]"
 | 
			
		||||
              >
 | 
			
		||||
                <Plus size={14} />
 | 
			
		||||
                <span>Add Effect</span>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default SoundEffectsControls
 | 
			
		||||
@ -0,0 +1,37 @@
 | 
			
		||||
import { useState, useCallback } from 'react'
 | 
			
		||||
 | 
			
		||||
export function useLocalStorage<T>(key: string, initialValue: T) {
 | 
			
		||||
  // Initialize state with a function to avoid unnecessary localStorage access during SSR
 | 
			
		||||
  const [storedValue, setStoredValue] = useState<T>(() => {
 | 
			
		||||
    if (typeof window === 'undefined') {
 | 
			
		||||
      return initialValue
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      const item = window.localStorage.getItem(key)
 | 
			
		||||
      return item ? (typeof initialValue === 'string' ? item as T : JSON.parse(item)) : initialValue
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`Error reading localStorage key "${key}":`, error)
 | 
			
		||||
      return initialValue
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // Memoize the setValue function to prevent unnecessary re-renders
 | 
			
		||||
  const setValue = useCallback((value: T | ((val: T) => T)) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const valueToStore = value instanceof Function ? value(storedValue) : value
 | 
			
		||||
      setStoredValue(valueToStore)
 | 
			
		||||
      
 | 
			
		||||
      if (typeof window !== 'undefined') {
 | 
			
		||||
        if (typeof valueToStore === 'string') {
 | 
			
		||||
          window.localStorage.setItem(key, valueToStore)
 | 
			
		||||
        } else {
 | 
			
		||||
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`Error setting localStorage key "${key}":`, error)
 | 
			
		||||
    }
 | 
			
		||||
  }, [key, storedValue])
 | 
			
		||||
 | 
			
		||||
  return [storedValue, setValue] as const
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,43 @@
 | 
			
		||||
export type Theme = {
 | 
			
		||||
  id: string
 | 
			
		||||
  name: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const themes: { [key: string]: Theme } = {
 | 
			
		||||
  dark: {
 | 
			
		||||
    id: 'dark',
 | 
			
		||||
    name: 'Dark',
 | 
			
		||||
  },
 | 
			
		||||
  light: {
 | 
			
		||||
    id: 'light',
 | 
			
		||||
    name: 'Light',
 | 
			
		||||
  },
 | 
			
		||||
  midnight: {
 | 
			
		||||
    id: 'midnight',
 | 
			
		||||
    name: 'Midnight',
 | 
			
		||||
  },
 | 
			
		||||
  metallic: {
 | 
			
		||||
    id: 'metallic',
 | 
			
		||||
    name: 'Metallic',
 | 
			
		||||
  },
 | 
			
		||||
  vintage: {
 | 
			
		||||
    id: 'vintage',
 | 
			
		||||
    name: 'Vintage',
 | 
			
		||||
  },
 | 
			
		||||
  cyberpunk: {
 | 
			
		||||
    id: 'cyberpunk',
 | 
			
		||||
    name: 'Cyberpunk',
 | 
			
		||||
  },
 | 
			
		||||
  forest: {
 | 
			
		||||
    id: 'forest',
 | 
			
		||||
    name: 'Forest',
 | 
			
		||||
  },
 | 
			
		||||
  ocean: {
 | 
			
		||||
    id: 'ocean',
 | 
			
		||||
    name: 'Ocean',
 | 
			
		||||
  },
 | 
			
		||||
  sunset: {
 | 
			
		||||
    id: 'sunset',
 | 
			
		||||
    name: 'Sunset',
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,82 @@
 | 
			
		||||
import { Cloud, Wind, Coffee, Users, Monitor, Power } from 'lucide-react'
 | 
			
		||||
import { Channel, SoundEffect } from '@/types/lofi'
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_CHANNELS: Channel[] = [
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Lofi Girl',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=jfKfPfyJRdk',
 | 
			
		||||
    description: 'Beats to relax/study to',
 | 
			
		||||
    creator: 'Lofi Girl',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Chillhop Radio',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=5yx6BWlEVcY',
 | 
			
		||||
    description: 'jazzy & lofi hip hop beats',
 | 
			
		||||
    creator: 'Chillhop Music',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Chilled Raccoon',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=7NOSDKb0HlU',
 | 
			
		||||
    description: 'late night lofi mix',
 | 
			
		||||
    creator: 'Chilled Raccoon',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Smooth Jazz',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=HhqWd3Axq9Y',
 | 
			
		||||
    description: 'warm jazz music at coffee shop',
 | 
			
		||||
    creator: 'Relax Jazz Cafe',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Tokyo night drive',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=Lcdi9O2XB4E',
 | 
			
		||||
    description: 'lofi hiphop + chill + beats',
 | 
			
		||||
    creator: 'Tokyo Tones',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: 'Japan Cafe Vibe',
 | 
			
		||||
    url: 'https://www.youtube.com/watch?v=bRnTGwCbr3E',
 | 
			
		||||
    description: 'Lofi Music to sleep,relax,study...',
 | 
			
		||||
    creator: 'Healing Me',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export const channels: Channel[] = [...DEFAULT_CHANNELS]
 | 
			
		||||
 | 
			
		||||
export const soundEffects: SoundEffect[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 'rain',
 | 
			
		||||
    name: 'Rain',
 | 
			
		||||
    icon: Cloud,
 | 
			
		||||
    file: '/sounds/rain.mp3',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'wind',
 | 
			
		||||
    name: 'Soft Wind',
 | 
			
		||||
    icon: Wind,
 | 
			
		||||
    file: '/sounds/wind.mp3',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'cafe',
 | 
			
		||||
    name: 'Cafe Ambience',
 | 
			
		||||
    icon: Coffee,
 | 
			
		||||
    file: '/sounds/cafe.mp3',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'people',
 | 
			
		||||
    name: 'Distant Voices',
 | 
			
		||||
    icon: Users,
 | 
			
		||||
    file: '/sounds/people.mp3',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'keyboard',
 | 
			
		||||
    name: 'Keyboard Typing',
 | 
			
		||||
    icon: Monitor,
 | 
			
		||||
    file: '/sounds/keyboard.mp3',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 'whitenoise',
 | 
			
		||||
    name: 'White Noise',
 | 
			
		||||
    icon: Power,
 | 
			
		||||
    file: '/sounds/whitenoise.mp3',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
import { clsx, type ClassValue } from "clsx"
 | 
			
		||||
import { twMerge } from "tailwind-merge"
 | 
			
		||||
 | 
			
		||||
export function cn(...inputs: ClassValue[]) {
 | 
			
		||||
  return twMerge(clsx(inputs))
 | 
			
		||||
}
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 391 B  | 
@ -1 +0,0 @@
 | 
			
		||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.0 KiB  | 
@ -1 +0,0 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
											
												Binary file not shown.
											
										
									
								
											
												Binary file not shown.
											
										
									
								
											
												Binary file not shown.
											
										
									
								
											
												Binary file not shown.
											
										
									
								
											
												Binary file not shown.
											
										
									
								
											
												Binary file not shown.
											
										
									
								@ -1 +0,0 @@
 | 
			
		||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 128 B  | 
@ -1 +0,0 @@
 | 
			
		||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 385 B  | 
@ -0,0 +1,166 @@
 | 
			
		||||
.theme-container {
 | 
			
		||||
  /* Dark theme (default) */
 | 
			
		||||
  --lofi-background: #18181b;
 | 
			
		||||
  --lofi-card: #27272a;
 | 
			
		||||
  --lofi-card-hover: #3f3f46;
 | 
			
		||||
  --lofi-border: #3f3f46;
 | 
			
		||||
  --lofi-text-primary: #e4e4e7;
 | 
			
		||||
  --lofi-text-secondary: #a1a1aa;
 | 
			
		||||
  --lofi-button-bg: #3f3f46;
 | 
			
		||||
  --lofi-button-hover: #52525b;
 | 
			
		||||
  --lofi-button-text: #e4e4e7;
 | 
			
		||||
  --lofi-accent: #7e22ce;
 | 
			
		||||
  --lofi-accent-hover: #9333ea;
 | 
			
		||||
  --lofi-card-radius: 0.75rem;
 | 
			
		||||
  --lofi-button-radius: 0.5rem;
 | 
			
		||||
  --lofi-card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='light'] {
 | 
			
		||||
  --lofi-background: #f4f4f5;
 | 
			
		||||
  --lofi-card: #e4e4e7;
 | 
			
		||||
  --lofi-card-hover: #d4d4d8;
 | 
			
		||||
  --lofi-border: #d4d4d8;
 | 
			
		||||
  --lofi-text-primary: #18181b;
 | 
			
		||||
  --lofi-text-secondary: #71717a;
 | 
			
		||||
  --lofi-button-bg: #d4d4d8;
 | 
			
		||||
  --lofi-button-hover: #a1a1aa;
 | 
			
		||||
  --lofi-button-text: #27272a;
 | 
			
		||||
  --lofi-accent: #7e22ce;
 | 
			
		||||
  --lofi-accent-hover: #9333ea;
 | 
			
		||||
  --lofi-card-radius: 0.75rem;
 | 
			
		||||
  --lofi-button-radius: 0.5rem;
 | 
			
		||||
  --lofi-card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='vintage'] {
 | 
			
		||||
  --lofi-background: #f8e3c4; /* Warm cream background */
 | 
			
		||||
  --lofi-card: #e6d0b0; /* Lighter cream for cards */
 | 
			
		||||
  --lofi-card-hover: #d4be9c; /* Slightly darker on hover */
 | 
			
		||||
  --lofi-border: #8b6d5c; /* Rich brown border */
 | 
			
		||||
  --lofi-text-primary: #4a3628; /* Deep brown text */
 | 
			
		||||
  --lofi-text-secondary: #6b5a4c; /* Medium brown text */
 | 
			
		||||
  --lofi-button-bg: #c17f59; /* Terracotta buttons */
 | 
			
		||||
  --lofi-button-hover: #d4956f; /* Lighter terracotta on hover */
 | 
			
		||||
  --lofi-button-text: #f8e3c4; /* Light cream text */
 | 
			
		||||
  --lofi-accent: #8b6d5c; /* Rich brown accent */
 | 
			
		||||
  --lofi-accent-hover: #9d7e6d; /* Lighter brown on hover */
 | 
			
		||||
  --lofi-card-radius: 0.5rem; /* Subtle rounding */
 | 
			
		||||
  --lofi-button-radius: 0.25rem; /* Minimal button rounding */
 | 
			
		||||
  --lofi-card-shadow: 0 4px 12px rgba(139, 109, 92, 0.2),
 | 
			
		||||
    0 0 0 1px rgba(139, 109, 92, 0.15), inset 0 1px 0 0 rgba(248, 227, 196, 0.5),
 | 
			
		||||
    2px 2px 0 rgba(139, 109, 92, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='metallic'] {
 | 
			
		||||
  --lofi-background: #0f1215; /* Nearly black background */
 | 
			
		||||
  --lofi-card: #1a1f25; /* Dark gunmetal */
 | 
			
		||||
  --lofi-card-hover: #252b33; /* Lighter gunmetal on hover */
 | 
			
		||||
  --lofi-border: #3a424d; /* Medium steel */
 | 
			
		||||
  --lofi-text-primary: #e2e8f0; /* Bright steel */
 | 
			
		||||
  --lofi-text-secondary: #94a3b8; /* Muted steel */
 | 
			
		||||
  --lofi-button-bg: #2d343d; /* Dark steel buttons */
 | 
			
		||||
  --lofi-button-hover: #3a424d; /* Medium steel on hover */
 | 
			
		||||
  --lofi-button-text: #e2e8f0; /* Bright steel text */
 | 
			
		||||
  --lofi-accent: #718096; /* Cool gray accent */
 | 
			
		||||
  --lofi-accent-hover: #8b97aa; /* Lighter steel on hover */
 | 
			
		||||
  --lofi-card-radius: 0.25rem; /* Sharp corners for metallic feel */
 | 
			
		||||
  --lofi-button-radius: 0.125rem; /* Very subtle button rounding */
 | 
			
		||||
  --lofi-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
 | 
			
		||||
    0 0 0 1px rgba(255, 255, 255, 0.08),
 | 
			
		||||
    inset 0 1px 0 0 rgba(255, 255, 255, 0.05), 0 0 20px rgba(0, 0, 0, 0.2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='midnight'] {
 | 
			
		||||
  --lofi-background: #0a0c10; /* Darker than default dark theme */
 | 
			
		||||
  --lofi-card: #141820; /* Deep blue-tinted dark */
 | 
			
		||||
  --lofi-card-hover: #1c2230; /* Slightly lighter on hover */
 | 
			
		||||
  --lofi-border: #252d3d; /* Subtle blue-gray border */
 | 
			
		||||
  --lofi-text-primary: #e2e8f0; /* Soft white with blue tint */
 | 
			
		||||
  --lofi-text-secondary: #94a3b8; /* Muted blue-gray */
 | 
			
		||||
  --lofi-button-bg: #252d3d; /* Deep blue-gray */
 | 
			
		||||
  --lofi-button-hover: #2f3b4f; /* Lighter blue-gray on hover */
 | 
			
		||||
  --lofi-button-text: #e2e8f0; /* Same as text-primary */
 | 
			
		||||
  --lofi-accent: #3b82f6; /* Bright blue accent */
 | 
			
		||||
  --lofi-accent-hover: #60a5fa; /* Lighter blue on hover */
 | 
			
		||||
  --lofi-card-radius: 0.75rem;
 | 
			
		||||
  --lofi-button-radius: 0.5rem;
 | 
			
		||||
  --lofi-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
 | 
			
		||||
    0 0 0 1px rgba(255, 255, 255, 0.05),
 | 
			
		||||
    inset 0 1px 0 0 rgba(255, 255, 255, 0.02);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='cyberpunk'] {
 | 
			
		||||
  --lofi-background: #0f0326; /* Deep purple background */
 | 
			
		||||
  --lofi-card: #1a0940; /* Richer purple for cards */
 | 
			
		||||
  --lofi-card-hover: #2a0c66; /* Brighter purple on hover */
 | 
			
		||||
  --lofi-border: #ff2a6d; /* Neon pink border */
 | 
			
		||||
  --lofi-text-primary: #05ffa1; /* Bright cyan text */
 | 
			
		||||
  --lofi-text-secondary: #14acc2; /* Muted cyan */
 | 
			
		||||
  --lofi-button-bg: #2a0c66; /* Deep purple buttons */
 | 
			
		||||
  --lofi-button-hover: #3d1a75; /* Lighter purple on hover */
 | 
			
		||||
  --lofi-button-text: #05ffa1; /* Cyan button text */
 | 
			
		||||
  --lofi-accent: #ff2a6d; /* Neon pink accent */
 | 
			
		||||
  --lofi-accent-hover: #ff4081; /* Lighter pink on hover */
 | 
			
		||||
  --lofi-card-radius: 0.25rem; /* Sharp corners for cyber feel */
 | 
			
		||||
  --lofi-button-radius: 0rem; /* Square buttons */
 | 
			
		||||
  --lofi-card-shadow: 0 0 10px rgba(255, 42, 109, 0.3),
 | 
			
		||||
    0 0 20px rgba(5, 255, 161, 0.1), inset 0 0 2px rgba(255, 42, 109, 0.5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='forest'] {
 | 
			
		||||
  --lofi-background: #1a2f1c; /* Deep forest green */
 | 
			
		||||
  --lofi-card: #2a412c; /* Darker green for cards */
 | 
			
		||||
  --lofi-card-hover: #35513e; /* Lighter green on hover */
 | 
			
		||||
  --lofi-border: #4a6b4d; /* Muted green border */
 | 
			
		||||
  --lofi-text-primary: #d4e5d4; /* Soft green-white */
 | 
			
		||||
  --lofi-text-secondary: #95ab96; /* Muted sage */
 | 
			
		||||
  --lofi-button-bg: #3c5a3e; /* Forest green buttons */
 | 
			
		||||
  --lofi-button-hover: #4a714d; /* Lighter green on hover */
 | 
			
		||||
  --lofi-button-text: #d4e5d4; /* Light text */
 | 
			
		||||
  --lofi-accent: #7fb069; /* Moss green accent */
 | 
			
		||||
  --lofi-accent-hover: #96c37c; /* Lighter moss on hover */
 | 
			
		||||
  --lofi-card-radius: 1.5rem; /* Organic, rounded corners */
 | 
			
		||||
  --lofi-button-radius: 1rem; /* Soft buttons */
 | 
			
		||||
  --lofi-card-shadow: 0 4px 12px rgba(0, 0, 0, 0.2),
 | 
			
		||||
    0 0 0 1px rgba(126, 176, 105, 0.1),
 | 
			
		||||
    inset 0 1px 0 0 rgba(212, 229, 212, 0.05);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='ocean'] {
 | 
			
		||||
  --lofi-background: #0a192f; /* Deep ocean blue */
 | 
			
		||||
  --lofi-card: #112240; /* Darker blue for cards */
 | 
			
		||||
  --lofi-card-hover: #1a365d; /* Lighter blue on hover */
 | 
			
		||||
  --lofi-border: #234876; /* Medium blue border */
 | 
			
		||||
  --lofi-text-primary: #e6f1ff; /* Soft blue-white */
 | 
			
		||||
  --lofi-text-secondary: #8892b0; /* Muted blue-gray */
 | 
			
		||||
  --lofi-button-bg: #1a365d; /* Ocean blue buttons */
 | 
			
		||||
  --lofi-button-hover: #234876; /* Lighter blue on hover */
 | 
			
		||||
  --lofi-button-text: #e6f1ff; /* Light text */
 | 
			
		||||
  --lofi-accent: #64ffda; /* Aqua accent */
 | 
			
		||||
  --lofi-accent-hover: #88fff0; /* Lighter aqua on hover */
 | 
			
		||||
  --lofi-card-radius: 0.5rem; /* Gentle waves */
 | 
			
		||||
  --lofi-button-radius: 0.25rem; /* Subtle curves */
 | 
			
		||||
  --lofi-card-shadow: 0 4px 15px rgba(100, 255, 218, 0.07),
 | 
			
		||||
    0 0 0 1px rgba(100, 255, 218, 0.1),
 | 
			
		||||
    inset 0 1px 0 0 rgba(230, 241, 255, 0.05);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.theme-container[data-theme='sunset'] {
 | 
			
		||||
  --lofi-background: #2d1b2d; /* Deep purple-red */
 | 
			
		||||
  --lofi-card: #3d2438; /* Darker mauve for cards */
 | 
			
		||||
  --lofi-card-hover: #4d2d47; /* Lighter mauve on hover */
 | 
			
		||||
  --lofi-border: #ff6b6b; /* Coral border */
 | 
			
		||||
  --lofi-text-primary: #ffd6d6; /* Soft pink-white */
 | 
			
		||||
  --lofi-text-secondary: #c4a5a5; /* Muted pink */
 | 
			
		||||
  --lofi-button-bg: #ff6b6b; /* Coral buttons */
 | 
			
		||||
  --lofi-button-hover: #ff8787; /* Lighter coral on hover */
 | 
			
		||||
  --lofi-button-text: #2d1b2d; /* Dark text on buttons */
 | 
			
		||||
  --lofi-accent: #ffc145; /* Golden accent */
 | 
			
		||||
  --lofi-accent-hover: #ffd175; /* Lighter gold on hover */
 | 
			
		||||
  --lofi-card-radius: 1rem; /* Soft corners */
 | 
			
		||||
  --lofi-button-radius: 0.75rem; /* Rounded buttons */
 | 
			
		||||
  --lofi-card-shadow: 0 4px 12px rgba(255, 107, 107, 0.1),
 | 
			
		||||
    0 0 0 1px rgba(255, 107, 107, 0.1),
 | 
			
		||||
    inset 0 1px 0 0 rgba(255, 214, 214, 0.05);
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,72 @@
 | 
			
		||||
@tailwind base;
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  font-family: Arial, Helvetica, sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: 0 0% 100%;
 | 
			
		||||
    --foreground: 0 0% 3.9%;
 | 
			
		||||
    --card: 0 0% 100%;
 | 
			
		||||
    --card-foreground: 0 0% 3.9%;
 | 
			
		||||
    --popover: 0 0% 100%;
 | 
			
		||||
    --popover-foreground: 0 0% 3.9%;
 | 
			
		||||
    --primary: 0 0% 9%;
 | 
			
		||||
    --primary-foreground: 0 0% 98%;
 | 
			
		||||
    --secondary: 0 0% 96.1%;
 | 
			
		||||
    --secondary-foreground: 0 0% 9%;
 | 
			
		||||
    --muted: 0 0% 96.1%;
 | 
			
		||||
    --muted-foreground: 0 0% 45.1%;
 | 
			
		||||
    --accent: 0 0% 96.1%;
 | 
			
		||||
    --accent-foreground: 0 0% 9%;
 | 
			
		||||
    --destructive: 0 84.2% 60.2%;
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
    --border: 0 0% 89.8%;
 | 
			
		||||
    --input: 0 0% 89.8%;
 | 
			
		||||
    --ring: 0 0% 3.9%;
 | 
			
		||||
    --chart-1: 12 76% 61%;
 | 
			
		||||
    --chart-2: 173 58% 39%;
 | 
			
		||||
    --chart-3: 197 37% 24%;
 | 
			
		||||
    --chart-4: 43 74% 66%;
 | 
			
		||||
    --chart-5: 27 87% 67%;
 | 
			
		||||
    --radius: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
  .dark {
 | 
			
		||||
    --background: 0 0% 3.9%;
 | 
			
		||||
    --foreground: 0 0% 98%;
 | 
			
		||||
    --card: 0 0% 3.9%;
 | 
			
		||||
    --card-foreground: 0 0% 98%;
 | 
			
		||||
    --popover: 0 0% 3.9%;
 | 
			
		||||
    --popover-foreground: 0 0% 98%;
 | 
			
		||||
    --primary: 0 0% 98%;
 | 
			
		||||
    --primary-foreground: 0 0% 9%;
 | 
			
		||||
    --secondary: 0 0% 14.9%;
 | 
			
		||||
    --secondary-foreground: 0 0% 98%;
 | 
			
		||||
    --muted: 0 0% 14.9%;
 | 
			
		||||
    --muted-foreground: 0 0% 63.9%;
 | 
			
		||||
    --accent: 0 0% 14.9%;
 | 
			
		||||
    --accent-foreground: 0 0% 98%;
 | 
			
		||||
    --destructive: 0 62.8% 30.6%;
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
    --border: 0 0% 14.9%;
 | 
			
		||||
    --input: 0 0% 14.9%;
 | 
			
		||||
    --ring: 0 0% 83.1%;
 | 
			
		||||
    --chart-1: 220 70% 50%;
 | 
			
		||||
    --chart-2: 160 60% 45%;
 | 
			
		||||
    --chart-3: 30 80% 55%;
 | 
			
		||||
    --chart-4: 280 65% 60%;
 | 
			
		||||
    --chart-5: 340 75% 55%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  * {
 | 
			
		||||
    @apply border-border;
 | 
			
		||||
  }
 | 
			
		||||
  body {
 | 
			
		||||
    @apply bg-background text-foreground;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,18 +1,62 @@
 | 
			
		||||
import type { Config } from "tailwindcss";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  content: [
 | 
			
		||||
    darkMode: ["class"],
 | 
			
		||||
    content: [
 | 
			
		||||
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
 | 
			
		||||
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
 | 
			
		||||
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
 | 
			
		||||
  ],
 | 
			
		||||
  theme: {
 | 
			
		||||
    extend: {
 | 
			
		||||
      colors: {
 | 
			
		||||
        background: "var(--background)",
 | 
			
		||||
        foreground: "var(--foreground)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  	extend: {
 | 
			
		||||
  		colors: {
 | 
			
		||||
  			background: 'hsl(var(--background))',
 | 
			
		||||
  			foreground: 'hsl(var(--foreground))',
 | 
			
		||||
  			card: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--card))',
 | 
			
		||||
  				foreground: 'hsl(var(--card-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			popover: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--popover))',
 | 
			
		||||
  				foreground: 'hsl(var(--popover-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			primary: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--primary))',
 | 
			
		||||
  				foreground: 'hsl(var(--primary-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			secondary: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--secondary))',
 | 
			
		||||
  				foreground: 'hsl(var(--secondary-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			muted: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--muted))',
 | 
			
		||||
  				foreground: 'hsl(var(--muted-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			accent: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--accent))',
 | 
			
		||||
  				foreground: 'hsl(var(--accent-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			destructive: {
 | 
			
		||||
  				DEFAULT: 'hsl(var(--destructive))',
 | 
			
		||||
  				foreground: 'hsl(var(--destructive-foreground))'
 | 
			
		||||
  			},
 | 
			
		||||
  			border: 'hsl(var(--border))',
 | 
			
		||||
  			input: 'hsl(var(--input))',
 | 
			
		||||
  			ring: 'hsl(var(--ring))',
 | 
			
		||||
  			chart: {
 | 
			
		||||
  				'1': 'hsl(var(--chart-1))',
 | 
			
		||||
  				'2': 'hsl(var(--chart-2))',
 | 
			
		||||
  				'3': 'hsl(var(--chart-3))',
 | 
			
		||||
  				'4': 'hsl(var(--chart-4))',
 | 
			
		||||
  				'5': 'hsl(var(--chart-5))'
 | 
			
		||||
  			}
 | 
			
		||||
  		},
 | 
			
		||||
  		borderRadius: {
 | 
			
		||||
  			lg: 'var(--radius)',
 | 
			
		||||
  			md: 'calc(var(--radius) - 2px)',
 | 
			
		||||
  			sm: 'calc(var(--radius) - 4px)'
 | 
			
		||||
  		}
 | 
			
		||||
  	}
 | 
			
		||||
  },
 | 
			
		||||
  plugins: [],
 | 
			
		||||
  plugins: [require("tailwindcss-animate")],
 | 
			
		||||
} satisfies Config;
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,25 @@
 | 
			
		||||
export interface Channel {
 | 
			
		||||
  originalIndex?: number
 | 
			
		||||
  name: string
 | 
			
		||||
  url: string
 | 
			
		||||
  description: string
 | 
			
		||||
  creator: string
 | 
			
		||||
  isCustom?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface SoundEffect {
 | 
			
		||||
  id: string
 | 
			
		||||
  name: string
 | 
			
		||||
  icon: any
 | 
			
		||||
  file: string
 | 
			
		||||
  isCustom?: boolean
 | 
			
		||||
  isYoutube?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CustomSoundEffect {
 | 
			
		||||
  id: string
 | 
			
		||||
  name: string
 | 
			
		||||
  file: string
 | 
			
		||||
  isCustom?: boolean
 | 
			
		||||
  isYoutube?: boolean
 | 
			
		||||
}
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue