btahir89 12 months ago
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,27 +1,27 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import type { Metadata } from 'next'
import localFont from 'next/font/local'
import '@/styles/globals.css'
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
weight: '100 900',
})
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
src: './fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
weight: '100 900',
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html lang="en">
@ -31,5 +31,5 @@ export default function RootLayout({
{children}
</body>
</html>
);
)
}

@ -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))
}

107
package-lock.json generated

@ -8,9 +8,15 @@
"name": "next-beats",
"version": "0.1.0",
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-player": "^2.16.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20",
@ -1514,11 +1520,38 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz",
"integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==",
"dependencies": {
"clsx": "2.0.0"
},
"funding": {
"url": "https://joebell.co.uk"
}
},
"node_modules/class-variance-authority/node_modules/clsx": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -1687,6 +1720,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@ -3321,8 +3362,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
@ -3436,6 +3476,11 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
"node_modules/load-script": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -3461,7 +3506,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -3475,6 +3519,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/lucide-react": {
"version": "0.460.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz",
"integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3660,7 +3717,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -4111,7 +4167,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -4166,11 +4221,30 @@
"react": "19.0.0-rc-66855b96-20241106"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-player": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz",
"integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==",
"dependencies": {
"deepmerge": "^4.0.0",
"load-script": "^1.0.0",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.0.1"
},
"peerDependencies": {
"react": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
@ -4837,6 +4911,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
"integrity": "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz",
@ -4874,6 +4957,14 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tailwindcss/node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",

@ -9,18 +9,24 @@
"lint": "next lint"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"next": "15.0.3"
"react-player": "^2.16.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "15.0.3"
"typescript": "^5"
}
}

@ -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…
Cancel
Save