You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

632 lines
22 KiB
TypeScript

'use client'
import React, { useState, useRef, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { X, Save, Settings } from 'lucide-react'
import { GitHubIcon } from '@/components/GitHubIcon'
import { soundEffects, DEFAULT_CHANNELS } from '@/lib/lofi_data'
import ChannelButtons from '@/components/ChannelButtons'
import PlaybackControls from '@/components/PlaybackControls'
import ChannelManagement from '@/components/ChannelManagement'
import SoundEffectsControls from '@/components/SoundEffectsControls'
import { useLocalStorage } from '@/hooks/useLocalStorage'
import { Channel, CustomSoundEffect } from '@/types/lofi'
import SettingsModal from '@/components/SettingsModal'
import styles from '@/styles/Lofi.module.css'
const ReactPlayer = dynamic(() => import('react-player/youtube'), {
ssr: false,
})
// Type Definitions
type AudioCache = {
audio: HTMLAudioElement
loaded: boolean
}
const StaticEffect = () => {
const [staticPoints, setStaticPoints] = useState<
{ left: string; top: string; opacity: number }[]
>([])
useEffect(() => {
setStaticPoints(
Array.from({ length: 100 }, () => ({
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
opacity: Math.random() * 0.5,
}))
)
}, [])
return (
<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 [activeEffects, setActiveEffects] = 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(() => {
if (typeof window !== 'undefined') {
// Get theme from localStorage directly to ensure immediate application
const savedTheme = localStorage.getItem('lofi-theme') || 'dark'
document.documentElement.dataset.theme = savedTheme
if (currentTheme !== savedTheme) {
setCurrentTheme(savedTheme)
}
}
}, [])
const toggleEffect = (effectId: string) => {
setActiveEffects((prev) => {
const newEffects = new Set(prev)
if (newEffects.has(effectId)) {
newEffects.delete(effectId)
} else {
newEffects.add(effectId)
}
return newEffects
})
}
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)
}
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,
})
}
return (
<div
className={styles['theme-container']}
data-theme={mounted ? currentTheme : 'dark'}
>
<a
href="https://github.com/btahir/next-beats"
target="_blank"
rel="noopener noreferrer"
className="fixed right-4 top-4 hidden text-[var(--lofi-text-primary)] transition-opacity hover:opacity-70 lg:block"
aria-label="View source on GitHub"
>
<GitHubIcon />
</a>
<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 gap-3">
{/* Left Side - Playback Controls */}
{mounted && (
<PlaybackControls
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
volume={volume}
setVolume={handleVolumeChange}
changeChannel={changeChannel}
/>
)}
{/* Right Side - Channel Management */}
{mounted && (
<div className="flex shrink-0 items-center">
<ChannelManagement
isAddingChannel={isAddingChannel}
setIsAddingChannel={setIsAddingChannel}
newChannel={newChannel}
setNewChannel={setNewChannel}
saveChannel={handleSaveChannel}
currentTheme={currentTheme}
currentChannel={currentChannel}
handleEditChannel={handleEditChannel}
setShowDeleteConfirm={setShowDeleteConfirm}
/>
</div>
)}
</div>
{/* Progress Bar */}
<div className="">
<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={new Set()}
/>
</div>
)}
</div>
{/* 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}
/>
</div>
</div>
)
}
export default EnhancedLofiPlayer