Files
shpm/client/src/components/MemoriesView.jsx
T
nmemmertandClaude Sonnet 4.6 6f4e90a1c8 feat: play real music from local library in Memories slideshow
Scans MUSIC_DIR (mounted at /music in Docker) with music-metadata to
read embedded BPM/genre tags and classify tracks as upbeat/cinematic/
dreamy. Falls back to untagged general pool when mood bucket is thin.
Slideshow plays real audio via <audio> element with range-request
streaming; Web Audio synth is kept as fallback when MUSIC_DIR is unset.
Shows now-playing title/artist in the slideshow bottom bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-18 15:26:18 -04:00

523 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (!groups) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
const noPhotos = allPhotos.length === 0;
const dateLabel = todayLabel();
return (
<>
{/* ── Main view ── */}
<div style={{ padding: '28px 24px', maxWidth: 980, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 14, marginBottom: 32 }}>
<div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: '#e8e8e8', margin: 0, marginBottom: 5, letterSpacing: -0.4 }}>
{dateLabel}
</h1>
<p style={{ fontSize: 13, color: '#666', margin: 0 }}>
{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' : ''}`}
</p>
</div>
{!noPhotos && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* Music style picker */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<div style={{ display: 'flex', gap: 4, background: '#111', borderRadius: 8, padding: 3 }}>
{MUSIC_STYLES.map(s => (
<button
key={s.key}
onClick={() => setMusicStyle(s.key)}
title={s.label}
style={{
background: musicStyle === s.key ? '#1e2a3a' : 'transparent',
border: 'none', borderRadius: 6,
color: musicStyle === s.key ? '#7ab8f5' : '#555',
cursor: 'pointer', padding: '5px 10px',
fontSize: 13, display: 'flex', alignItems: 'center', gap: 5,
transition: 'background 0.15s, color 0.15s',
}}
>
<span>{s.icon}</span>
<span style={{ fontSize: 12 }}>{s.label}</span>
</button>
))}
</div>
{scanInfo && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#444' }}>
{scanInfo.scanning ? '⟳ Scanning music…' : scanInfo.enabled ? `${(scanInfo.total ?? tracks.length || scanInfo.count).toLocaleString()} tracks` : ''}
</span>
{scanInfo.enabled && !scanInfo.scanning && (
<button
onClick={() => {
fetch('/api/music/scan', { method: 'POST' }).then(() =>
setScanInfo(s => ({ ...s, scanning: true }))
);
}}
style={{ background: 'none', border: 'none', color: '#444', cursor: 'pointer', fontSize: 11, padding: 0 }}
>
Rescan
</button>
)}
</div>
)}
</div>
{/* Play button */}
<button
onClick={() => startSlideshow(0)}
style={{
background: '#4d9eff', border: 'none', borderRadius: 8,
color: '#fff', cursor: 'pointer',
padding: '9px 18px', fontSize: 14, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 7,
}}
>
▶ Play
</button>
</div>
)}
</div>
{/* Empty state */}
{noPhotos && (
<div style={{ textAlign: 'center', paddingTop: 64, color: '#555' }}>
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.5 }}>📷</div>
<p style={{ fontSize: 15, marginBottom: 8, color: '#777' }}>Nothing from {dateLabel} yet.</p>
<p style={{ fontSize: 13 }}>Keep shooting — they'll appear here on this date next year.</p>
</div>
)}
{/* Year groups */}
{groups.map(({ year, photos: yPhotos }) => (
<div key={year} style={{ marginBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: '#ddd' }}>{year}</span>
<span style={{ fontSize: 12, color: '#666' }}>{yearsAgo(year)}</span>
<span style={{ fontSize: 12, color: '#444' }}>· {yPhotos.length} photo{yPhotos.length !== 1 ? 's' : ''}</span>
</div>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{yPhotos.map(photo => {
const idx = allPhotos.indexOf(photo);
return (
<div
key={photo.id}
onClick={() => startSlideshow(idx)}
style={{ width: 168, height: 126, borderRadius: 5, overflow: 'hidden', cursor: 'pointer', flexShrink: 0, background: '#111' }}
onMouseEnter={e => { e.currentTarget.style.opacity = '0.75'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }}
>
{photo.thumb_url
? <img src={photo.thumb_url} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#333', fontSize: 22 }}>▪</div>
}
</div>
);
})}
</div>
</div>
))}
</div>
{/* ── Slideshow overlay ── */}
{slideshow && allPhotos.length > 0 && (() => {
const photo = allPhotos[ssIndex];
const year = new Date(photo.date_taken).getFullYear();
const style = MUSIC_STYLES.find(s => s.key === musicStyle);
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 200, background: '#000', display: 'flex', flexDirection: 'column' }}>
{/* Photo */}
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
key={photo.id}
src={photo.preview_url || photo.thumb_url}
alt=""
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', opacity, transition: 'opacity 0.32s ease', userSelect: 'none' }}
/>
{/* Year badge */}
<div style={{
position: 'absolute', bottom: 22, left: 22,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
borderRadius: 9, padding: '10px 16px',
border: '1px solid rgba(255,255,255,0.07)',
}}>
<div style={{ fontSize: 30, fontWeight: 700, color: '#fff', lineHeight: 1, letterSpacing: -0.5 }}>{year}</div>
<div style={{ fontSize: 12, color: '#aaa', marginTop: 3 }}>{yearsAgo(year)}</div>
</div>
<button onClick={() => advance(-1)} style={navBtn('left')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>‹</button>
<button onClick={() => advance(1)} style={navBtn('right')} onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}>›</button>
</div>
{/* Hidden audio element for real music */}
{tracks.length > 0 && (
<audio
ref={audioRef}
key={tracks[trackIdx % tracks.length]?.id}
src={`/api/music/stream/${tracks[trackIdx % tracks.length]?.id}`}
autoPlay
onEnded={() => setTrackIdx(i => (i + 1) % tracks.length)}
onError={() => setTrackIdx(i => (i + 1) % tracks.length)}
style={{ display: 'none' }}
/>
)}
{/* Bottom bar */}
<div style={{ background: '#090909', borderTop: '1px solid #1a1a1a', padding: '10px 18px', display: 'flex', alignItems: 'center', gap: 12 }}>
{/* Dots */}
<div style={{ flex: 1, display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
{allPhotos.map((_, i) => (
<div
key={i}
onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 320); }}
style={{ width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3, background: i === ssIndex ? '#4d9eff' : '#2a2a2a', cursor: 'pointer', transition: 'width 0.25s, background 0.25s', flexShrink: 0 }}
/>
))}
</div>
{/* Now playing */}
{tracks.length > 0 && tracks[trackIdx % tracks.length] && (
<div style={{ fontSize: 11, color: '#555', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0 }}>
{tracks[trackIdx % tracks.length].title}
{tracks[trackIdx % tracks.length].artist
? ` — ${tracks[trackIdx % tracks.length].artist}` : ''}
</div>
)}
<span style={{ fontSize: 12, color: '#555', flexShrink: 0 }}>
{style?.icon} {ssIndex + 1} / {allPhotos.length}
</span>
<button onClick={toggleMute} title={muted ? 'Unmute' : 'Mute'} style={ctrlBtn}>
{muted ? '🔇' : '🎵'}
</button>
<button onClick={stopSlideshow} title="Exit (Esc)" style={ctrlBtn}>✕</button>
</div>
</div>
);
})()}
</>
);
}
function navBtn(side) {
return {
position: 'absolute', top: '50%', transform: 'translateY(-50%)', [side]: 14,
background: 'rgba(0,0,0,0.45)', border: 'none', borderRadius: 8,
color: '#fff', fontSize: 36, lineHeight: 1,
width: 44, height: 68,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: 0.5, transition: 'opacity 0.18s',
};
}
const ctrlBtn = {
background: 'transparent', border: '1px solid #222', borderRadius: 6,
color: '#888', cursor: 'pointer', padding: '5px 11px', fontSize: 14, flexShrink: 0,
};