diff --git a/.env.example b/.env.example index 0f4e325..5d2fd52 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,18 @@ +# App Configuration VITE_ADMIN_PIN=1234 -VITE_APP_TITLE=Dongho Kim +VITE_APP_TITLE=Dongho Kim Gallery VITE_APP_DESCRIPTION=My Photo Journey -VITE_APP_TITLE=Dongho Kim Gallery \ No newline at end of file + +# Navidrome Music Player Configuration +# Your Navidrome server URL (without trailing slash) +VITE_NAVIDROME_URL=https://navidrome.example.com + +# Your Navidrome username +VITE_NAVIDROME_USERNAME=d-username + +# Your Navidrome password (or use token authentication - see docs) +VITE_NAVIDROME_PASSWORD=your-password + +# Playlist ID (find it in the URL when viewing a playlist in Navidrome) +# Example: https://navidrome.example.com/app/playlist/abc123 -> use "abc123" +VITE_NAVIDROME_PLAYLIST_ID=your-playlist-id \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e0f14c5..8f8a045 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: - VITE_APP_TITLE=${VITE_APP_TITLE:-Chronicle} - VITE_APP_DESCRIPTION=${VITE_APP_DESCRIPTION:-A visual journey through time} volumes: - - ./uploads:/app/uploads + - /mnt/big/gallery:/app/uploads - ./data:/app/data restart: always diff --git a/index.html b/index.html index 921301e..db376af 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ + + + photo-showcase diff --git a/public/playlists/The Gallery.m3u b/public/playlists/The Gallery.m3u new file mode 100644 index 0000000..5be3f7a --- /dev/null +++ b/public/playlists/The Gallery.m3u @@ -0,0 +1,2 @@ +#EXTM3U +#PLAYLIST:The Gallery diff --git a/src/App.jsx b/src/App.jsx index f5d9b59..bba36e6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import { Sun, Moon } from 'lucide-react'; import Timeline from './components/Timeline'; import PhotoDetail from './components/PhotoDetail'; import Admin from './components/Admin'; +import MusicPlayer from './components/MusicPlayer'; import { fetchPhotos } from './data/photos'; function App() { @@ -115,6 +116,14 @@ function App() { onClose={() => setActivePhoto(null)} /> )} + + {/* Music Player */} + ); } diff --git a/src/components/Admin.jsx b/src/components/Admin.jsx index e7ade2c..bfee1f3 100644 --- a/src/components/Admin.jsx +++ b/src/components/Admin.jsx @@ -14,6 +14,9 @@ const Admin = ({ onBack, onUpdate }) => { const [notification, setNotification] = useState(null); // { type: 'success'|'error', message: '' } const [deleteTarget, setDeleteTarget] = useState(null); const [faviconFile, setFaviconFile] = useState(null); + const [isProcessingImage, setIsProcessingImage] = useState(false); + const [processingProgress, setProcessingProgress] = useState(0); + const [uploadProgress, setUploadProgress] = useState(0); const showNotification = (type, message) => { setNotification({ type, message }); @@ -59,11 +62,15 @@ const Admin = ({ onBack, onUpdate }) => { const handleImageChange = async (e) => { const file = e.target.files[0]; if (file) { + setIsProcessingImage(true); + setProcessingProgress(10); // Started setImageFile(file); // Extract Metadata try { const exifData = await exifr.parse(file); + setProcessingProgress(30); // EXIF extracted + if (exifData) { const make = exifData.Make || ''; const model = exifData.Model || ''; @@ -97,6 +104,8 @@ const Admin = ({ onBack, onUpdate }) => { } } + setProcessingProgress(50); // Location fetched + setFormData(prev => ({ ...prev, date: date, @@ -112,27 +121,37 @@ const Admin = ({ onBack, onUpdate }) => { } } catch (error) { console.warn("Failed to extract EXIF data", error); + setProcessingProgress(30); } // Handle Image Preview (Supports DNG/Raw via embedded thumbnail) let imageSource = null; let isBlob = false; + setProcessingProgress(70); // Starting preview generation + + let isDngFile = file.name.toLowerCase().endsWith('.dng'); + try { // Try to extract thumbnail if it looks like a raw file - if (file.name.toLowerCase().endsWith('.dng')) { + if (isDngFile) { const thumb = await exifr.thumbnail(file); if (thumb) { - imageSource = URL.createObjectURL(new Blob([thumb])); + imageSource = URL.createObjectURL(new Blob([thumb], { type: 'image/jpeg' })); isBlob = true; + console.log('Extracted thumbnail from DNG'); + } else { + console.warn('No thumbnail found in DNG file'); } } } catch (e) { console.warn("Could not extract thumbnail from DNG", e); } - // Fallback to standard FileReader if no thumbnail found or not a DNG - if (!imageSource) { + setProcessingProgress(80); // Thumbnail extracted or skipped + + // For regular images (not DNG) or if we successfully extracted a thumbnail + if (!imageSource && !isDngFile) { imageSource = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); @@ -140,29 +159,88 @@ const Admin = ({ onBack, onUpdate }) => { }); } + // If we still don't have an image source (DNG without thumbnail), show a placeholder + if (!imageSource) { + console.warn('Cannot generate preview for this file'); + showNotification('error', 'Cannot preview DNG files without embedded thumbnails. The file will still upload.'); + setPreview(''); // Clear preview + setIsProcessingImage(false); + setProcessingProgress(0); + return; + } + + setProcessingProgress(90); // Image loaded + + console.log('Image source type:', isBlob ? 'blob' : 'data URL'); + console.log('Image source length:', imageSource?.length || 'N/A'); + console.log('Image source preview:', imageSource?.substring(0, 100)); + + if (!imageSource) { + console.error('No image source available'); + showNotification('error', 'Failed to read image file'); + setIsProcessingImage(false); + setProcessingProgress(0); + return; + } + const img = new Image(); - img.src = imageSource; - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const maxWidth = 1200; // Limit width - const scale = maxWidth / img.width; - if (scale < 1) { - canvas.width = maxWidth; - canvas.height = img.height * scale; - } else { - canvas.width = img.width; - canvas.height = img.height; - } - - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - setPreview(canvas.toDataURL('image/jpeg', 0.8)); // Compress quality - - if (isBlob) { + // Set up handlers BEFORE setting src to avoid race condition + img.onerror = (error) => { + console.error("Failed to load image for preview", error); + console.error("Image src was:", img.src?.substring(0, 100)); + showNotification('error', 'Failed to process image preview'); + setIsProcessingImage(false); + setProcessingProgress(0); + if (isBlob && imageSource) { URL.revokeObjectURL(imageSource); } }; + + img.onload = () => { + console.log('Image loaded successfully, dimensions:', img.width, 'x', img.height); + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const maxWidth = 1200; // Limit width + const scale = maxWidth / img.width; + + if (scale < 1) { + canvas.width = maxWidth; + canvas.height = img.height * scale; + } else { + canvas.width = img.width; + canvas.height = img.height; + } + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + const previewDataUrl = canvas.toDataURL('image/jpeg', 0.8); + console.log('Preview created, size:', previewDataUrl.length); + setPreview(previewDataUrl); + + if (isBlob) { + URL.revokeObjectURL(imageSource); + } + + setProcessingProgress(100); // Complete + setTimeout(() => { + setIsProcessingImage(false); + setProcessingProgress(0); + }, 300); // Small delay to show 100% + } catch (error) { + console.error("Error creating preview:", error); + showNotification('error', 'Failed to create image preview'); + setIsProcessingImage(false); + setProcessingProgress(0); + if (isBlob && imageSource) { + URL.revokeObjectURL(imageSource); + } + } + }; + + // Set src AFTER handlers are attached + console.log('Setting image src...'); + img.src = imageSource; } }; @@ -176,6 +254,7 @@ const Admin = ({ onBack, onUpdate }) => { } setIsSubmitting(true); + setUploadProgress(0); const metadata = { title: formData.title, @@ -194,7 +273,9 @@ const Admin = ({ onBack, onUpdate }) => { }; try { - await uploadPhoto(imageFile, metadata); + await uploadPhoto(imageFile, metadata, (progress) => { + setUploadProgress(progress); + }); showNotification('success', 'Photo added successfully!'); // Reset form setFormData({ @@ -212,11 +293,13 @@ const Admin = ({ onBack, onUpdate }) => { }); setPreview(''); setImageFile(null); + setUploadProgress(0); refreshList(); if (onUpdate) onUpdate(); } catch (err) { showNotification('error', 'Failed to upload.'); console.error(err); + setUploadProgress(0); } finally { setIsSubmitting(false); } @@ -304,10 +387,10 @@ const Admin = ({ onBack, onUpdate }) => { -
+
{/* Upload Form */} -
-

+
+

Upload Photo

@@ -322,23 +405,66 @@ const Admin = ({ onBack, onUpdate }) => { display: 'flex', alignItems: 'center', gap: '8px', - fontSize: '0.9rem' + fontSize: '0.9rem', + flexShrink: 0 }}> {notification.type === 'success' ? : } {notification.message}
)} -
+
- {preview ? ( + {isProcessingImage ? ( +
+
+ {/* Background circle */} + + + {/* Progress circle */} + + + {/* Percentage text */} +
+ {processingProgress}% +
+
+
Processing image...
+
+ ) : preview ? ( Preview ) : (
@@ -371,19 +497,65 @@ const Admin = ({ onBack, onUpdate }) => { rows={3} /> -
{/* List of uploaded photos */} -
-

Your Uploads

+
+

Your Uploads

{localPhotos.length === 0 ? (

No local uploads yet.

) : ( -
+
{localPhotos.map(photo => (
{photo.title} @@ -515,6 +687,10 @@ const Admin = ({ onBack, onUpdate }) => { .input-field:focus { outline: 2px solid var(--accent); } + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } `}
); diff --git a/src/components/MusicPlayer.jsx b/src/components/MusicPlayer.jsx new file mode 100644 index 0000000..5bc0091 --- /dev/null +++ b/src/components/MusicPlayer.jsx @@ -0,0 +1,376 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Play, Pause, SkipForward, Volume2, VolumeX } from 'lucide-react'; + +const MusicPlayer = ({ navidromeUrl, username, password, playlistId }) => { + const [isPlaying, setIsPlaying] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [currentTrackIndex, setCurrentTrackIndex] = useState(0); + const [tracks, setTracks] = useState([]); + const [isExpanded, setIsExpanded] = useState(false); + const [currentTrack, setCurrentTrack] = useState(null); + const [hasInteracted, setHasInteracted] = useState(false); + const [readyToPlay, setReadyToPlay] = useState(false); + const audioRef = useRef(null); + + // Set volume to 30% on mount + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = 0.3; + } + }, []); + + // Fetch playlist from Navidrome API + useEffect(() => { + if (!navidromeUrl || !username || !password || !playlistId) { + return; + } + + const fetchPlaylist = async () => { + try { + // URL encode credentials to handle special characters + const encodedUsername = encodeURIComponent(username); + const encodedPassword = encodeURIComponent(password); + const encodedPlaylistId = encodeURIComponent(playlistId); + + // Construct Navidrome API URL - ensure f=json for JSON response + const apiUrl = `${navidromeUrl}/rest/getPlaylist.view?id=${encodedPlaylistId}&u=${encodedUsername}&p=${encodedPassword}&v=1.16.1&c=PhotoShowcase&f=json`; + + const response = await fetch(apiUrl); + const text = await response.text(); + + // Try to parse as JSON + let data; + try { + data = JSON.parse(text); + } catch (parseError) { + return; + } + + if (data['subsonic-response']?.status === 'ok') { + const playlist = data['subsonic-response'].playlist; + const entries = playlist.entry || []; + + // Build track list with stream URLs (use encoded credentials) + const trackList = entries.map(entry => ({ + id: entry.id, + title: entry.title, + artist: entry.artist, + streamUrl: `${navidromeUrl}/rest/stream.view?id=${encodeURIComponent(entry.id)}&u=${encodedUsername}&p=${encodedPassword}&v=1.16.1&c=PhotoShowcase` + })); + + setTracks(trackList); + + if (trackList.length > 0 && audioRef.current) { + setCurrentTrack(trackList[0]); + audioRef.current.src = trackList[0].streamUrl; + + // Mark as ready to play + setReadyToPlay(true); + + // Try autoplay, but don't worry if it fails + audioRef.current.play().catch(() => { }); + } + } + } catch (error) { + } + }; + + fetchPlaylist(); + }, [navidromeUrl, username, password, playlistId]); + + // Auto-play on first user interaction + useEffect(() => { + if (hasInteracted || !readyToPlay || !audioRef.current) return; + + const startPlayback = () => { + if (!hasInteracted && audioRef.current && readyToPlay) { + audioRef.current.play().then(() => { + setHasInteracted(true); + }).catch(() => { }); + } + }; + + // Listen for various user interactions + const events = ['click', 'scroll', 'touchstart', 'keydown', 'mousemove']; + + events.forEach(event => { + document.addEventListener(event, startPlayback, { once: true }); + }); + + return () => { + events.forEach(event => { + document.removeEventListener(event, startPlayback); + }); + }; + }, [hasInteracted, readyToPlay]); + + const togglePlay = () => { + if (!audioRef.current || tracks.length === 0) return; + + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + }; + + const toggleMute = () => { + if (!audioRef.current) return; + audioRef.current.muted = !isMuted; + setIsMuted(!isMuted); + }; + + const skipTrack = (autoPlay = false) => { + if (tracks.length === 0) return; + + const nextIndex = (currentTrackIndex + 1) % tracks.length; + setCurrentTrackIndex(nextIndex); + setCurrentTrack(tracks[nextIndex]); + + if (audioRef.current) { + audioRef.current.src = tracks[nextIndex].streamUrl; + // Play if currently playing OR if autoPlay is true (from track end) + if (isPlaying || autoPlay) { + audioRef.current.play(); + } + } + }; + + const handleTrackEnd = () => { + skipTrack(true); // Auto-play next track + }; + + return ( + <> + + +
+ {/* Controls Panel */} +
+ + +
+ + {/* Vinyl Button */} +
setIsExpanded(true)} + onMouseLeave={() => setIsExpanded(false)} + style={{ + animation: isPlaying ? 'spin 3s linear infinite' : 'none' + }} + title={isPlaying ? 'Pause' : 'Play'} + > +
+
+ {isPlaying ? ( + + ) : ( + + )} +
+
+
+
+ + {/* Hidden Audio Element */} +