import { useState, useEffect, useRef, useCallback } from 'react'; import { fetchMemories } from '../api/photos.js'; // ── Music engine ─────────────────────────────────────────────────────────────── function makeReverb(ctx, seconds) { const conv = ctx.createConvolver(); const sr = ctx.sampleRate; const buf = ctx.createBuffer(2, sr * seconds, sr); for (let ch = 0; ch < 2; ch++) { const d = buf.getChannelData(ch); for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6); } conv.buffer = buf; return conv; } function makeChain(ctx, { reverbSec, lpFreq, wet, dry }) { const master = ctx.createGain(); master.connect(ctx.destination); const reverb = makeReverb(ctx, reverbSec); const wg = ctx.createGain(); wg.gain.value = wet; const dg = ctx.createGain(); dg.gain.value = dry; reverb.connect(wg); wg.connect(master); dg.connect(master); const lpf = ctx.createBiquadFilter(); lpf.type = 'lowpass'; lpf.frequency.value = lpFreq; lpf.Q.value = 0.4; lpf.connect(reverb); lpf.connect(dg); return { master, lpf }; } function runMelody(ctx, { scale, spacing, dur, gain, dest, delay }) { let cancelled = false, t = ctx.currentTime + delay, idx = 0; function next() { if (cancelled) return; const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = scale[idx++ % scale.length]; env.gain.setValueAtTime(0, t); env.gain.linearRampToValueAtTime(gain, t + 0.05); env.gain.setValueAtTime(gain, t + dur * 0.5); env.gain.exponentialRampToValueAtTime(0.001, t + dur); osc.connect(env); env.connect(dest); osc.start(t); osc.stop(t + dur); t += spacing; setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000)); } next(); return () => { cancelled = true; }; } function runPluck(ctx, { scale, spacing, dur, gain, dest, delay }) { // Shorter, punchier envelope for upbeat feel let cancelled = false, t = ctx.currentTime + delay, idx = 0; function next() { if (cancelled) return; const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = 'triangle'; osc.frequency.value = scale[idx++ % scale.length]; env.gain.setValueAtTime(0, t); env.gain.linearRampToValueAtTime(gain, t + 0.02); env.gain.exponentialRampToValueAtTime(0.001, t + dur); osc.connect(env); env.connect(dest); osc.start(t); osc.stop(t + dur + 0.05); t += spacing; setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000)); } next(); return () => { cancelled = true; }; } // ── Style: Upbeat (G major, bright, bouncy) ──────────────────────────────────── function createUpbeat(ctx) { const now = ctx.currentTime; const { master, lpf } = makeChain(ctx, { reverbSec: 2, lpFreq: 3200, wet: 0.4, dry: 0.6 }); master.gain.setValueAtTime(0, now); master.gain.linearRampToValueAtTime(0.45, now + 1.5); // G major pad: G3, B3, D4, G4 [196, 246.94, 293.66, 392].forEach((freq, i) => { const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = 'triangle'; osc.frequency.value = freq; env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(0.055 / (1 + i * 0.22), now + 0.8 + i * 0.35); osc.connect(env); env.connect(lpf); const lfo = ctx.createOscillator(), lfoG = ctx.createGain(); lfo.frequency.value = 0.18 + i * 0.06; lfoG.gain.value = freq * 0.0025; lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now); }); // G major pentatonic: G3, A3, B3, D4, E4, G4, A4, B4 const scale = [293.66, 329.63, 392, 440, 392, 329.63, 293.66, 246.94, 293.66, 392, 440, 493.88, 440, 392, 329.63, 246.94]; const cancel = runPluck(ctx, { scale, spacing: 0.85, dur: 0.65, gain: 0.11, dest: lpf, delay: 1.5 }); return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.8); } }; } // ── Style: Dreamy (A minor, slow, nostalgic) ──────────────────────────────────── function createDreamy(ctx) { const now = ctx.currentTime; const { master, lpf } = makeChain(ctx, { reverbSec: 4, lpFreq: 1900, wet: 0.65, dry: 0.35 }); master.gain.setValueAtTime(0, now); master.gain.linearRampToValueAtTime(0.38, now + 4); // A minor pad: A2, E3, A3, C4, E4, G4 [110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => { const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = i < 2 ? 'sine' : 'triangle'; osc.frequency.value = freq + (Math.random() - 0.5) * 0.25; env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65); osc.connect(env); env.connect(lpf); const lfo = ctx.createOscillator(), lfoG = ctx.createGain(); lfo.frequency.value = 0.07 + i * 0.02; lfoG.gain.value = freq * 0.0018; lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now); }); // A pentatonic minor melody const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63, 392, 440, 392, 329.63, 261.63, 293.66, 329.63]; const cancel = runMelody(ctx, { scale, spacing: 2.3, dur: 1.9, gain: 0.1, dest: lpf, delay: 5.5 }); return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); } }; } // ── Style: Cinematic (D minor, sweeping, dramatic) ───────────────────────────── function createCinematic(ctx) { const now = ctx.currentTime; const { master, lpf } = makeChain(ctx, { reverbSec: 5, lpFreq: 1500, wet: 0.75, dry: 0.25 }); master.gain.setValueAtTime(0, now); master.gain.linearRampToValueAtTime(0.42, now + 5); // D minor pad: D3, F3, A3, C4, F4 — dual oscillators slightly detuned for richness [[146.83, 0.07], [174.61, 0.055], [220, 0.05], [261.63, 0.04], [349.23, 0.03]].forEach(([freq, g], i) => { [-1, 1].forEach(side => { const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = freq * (1 + side * 0.0015); env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(g, now + 3 + i * 0.9); osc.connect(env); env.connect(lpf); osc.start(now); }); }); // D natural minor melody — sweeping and slow const scale = [220, 261.63, 293.66, 261.63, 220, 196, 174.61, 196, 220, 293.66, 261.63, 220, 196, 174.61, 196, 220]; const cancel = runMelody(ctx, { scale, spacing: 2.8, dur: 2.4, gain: 0.09, dest: lpf, delay: 6.5 }); return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 3); } }; } function createMusic(ctx, style) { if (style === 'dreamy') return createDreamy(ctx); if (style === 'cinematic') return createCinematic(ctx); return createUpbeat(ctx); } // ── Helpers ──────────────────────────────────────────────────────────────────── const MUSIC_STYLES = [ { key: 'upbeat', label: 'Upbeat', icon: '⚡' }, { key: 'dreamy', label: 'Dreamy', icon: '🌙' }, { key: 'cinematic', label: 'Cinematic', icon: '🎬' }, ]; function groupByYear(photos) { const map = {}; for (const p of photos) { const yr = new Date(p.date_taken).getFullYear(); (map[yr] ??= []).push(p); } return Object.entries(map) .sort(([a], [b]) => Number(a) - Number(b)) .map(([year, photos]) => ({ year: Number(year), photos })); } function yearsAgo(year) { const n = new Date().getFullYear() - year; if (n === 0) return 'This year'; return n === 1 ? '1 year ago' : `${n} years ago`; } function todayLabel() { return new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); } // ── Component ────────────────────────────────────────────────────────────────── export default function MemoriesView() { const [groups, setGroups] = useState(null); const [error, setError] = useState(null); const [musicStyle, setMusicStyle] = useState('upbeat'); const [slideshow, setSlideshow] = useState(false); const [ssIndex, setSsIndex] = useState(0); const [opacity, setOpacity] = useState(1); const [muted, setMuted] = useState(false); const [tracks, setTracks] = useState([]); const [trackIdx, setTrackIdx] = useState(0); const [scanInfo, setScanInfo] = useState(null); const audioCtxRef = useRef(null); const musicRef = useRef(null); const timerRef = useRef(null); const audioRef = useRef(null); useEffect(() => { fetchMemories() .then(d => setGroups(groupByYear(d.photos))) .catch(e => setError(e.message)); fetch('/api/music/scan-status') .then(r => r.json()).then(setScanInfo).catch(() => {}); }, []); // Re-fetch track list whenever mood changes useEffect(() => { fetch(`/api/music?mood=${musicStyle}`) .then(r => r.json()) .then(d => { setTracks(d.tracks ?? []); setTrackIdx(0); }) .catch(() => setTracks([])); }, [musicStyle]); useEffect(() => () => { stopMusic(); clearInterval(timerRef.current); }, []); const allPhotos = groups ? groups.flatMap(g => g.photos) : []; // ── Music ── function startMusic(style) { if (audioCtxRef.current) return; const ctx = new (window.AudioContext || window.webkitAudioContext)(); audioCtxRef.current = ctx; musicRef.current = createMusic(ctx, style); } function stopMusic() { musicRef.current?.stop(); const ctx = audioCtxRef.current; if (ctx) { setTimeout(() => { try { ctx.close(); } catch {} }, 3500); audioCtxRef.current = null; musicRef.current = null; } } function toggleMute() { const next = !muted; setMuted(next); if (tracks.length > 0) { if (audioRef.current) audioRef.current.volume = next ? 0 : 1; } else if (musicRef.current && audioCtxRef.current) { musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5); } } // ── Slideshow ── const advance = useCallback((dir) => { setOpacity(0); setTimeout(() => { setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length); setOpacity(1); }, 320); }, [allPhotos.length]); function startSlideshow(idx) { setSsIndex(idx); setOpacity(1); setSlideshow(true); setTrackIdx(0); if (tracks.length === 0) startMusic(musicStyle); // synth fallback } function stopSlideshow() { clearInterval(timerRef.current); setSlideshow(false); stopMusic(); setMuted(false); if (audioRef.current) { audioRef.current.pause(); audioRef.current.src = ''; } } useEffect(() => { if (!slideshow) return; timerRef.current = setInterval(() => advance(1), 5000); return () => clearInterval(timerRef.current); }, [slideshow, advance]); useEffect(() => { if (!slideshow) return; function onKey(e) { if (e.key === 'ArrowRight') advance(1); else if (e.key === 'ArrowLeft') advance(-1); else if (e.key === 'Escape') stopSlideshow(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [slideshow, advance]); // ── Render ── if (error) return
{error}
; if (!groups) returnLoading…
; const noPhotos = allPhotos.length === 0; const dateLabel = todayLabel(); return ( <> {/* ── Main view ── */}{noPhotos ? 'No photos from this day in your library yet' : `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`}
Nothing from {dateLabel} yet.
Keep shooting — they'll appear here on this date next year.