diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/app.js b/public/app.js deleted file mode 100644 index 30b6cb7..0000000 --- a/public/app.js +++ /dev/null @@ -1,446 +0,0 @@ -const app = document.querySelector("#app"); -const page = document.body.dataset.page || "client"; - -let state = { running: false, current: null, queue: [], downloads: {}, library: {} }; -let results = []; -let query = ""; -let audio; -let socket; -let statePoll; -let currentAudioId = null; -let adminAudioContext; -let adminAnalyser; -let adminData; -let adminSourceAudio; -let adminBroadcastFrame; -let visualLevels = []; -let visualFrame; -let primaryArtwork = ""; -const primaryColorFromArtwork = document.body.dataset.primaryColorFromArtwork === "true"; - -const placeholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Crect width='600' height='600' fill='%2316161d'/%3E%3Cpath d='M395 145v236a78 78 0 1 1-32-63V218l-164 33v163a78 78 0 1 1-32-63V203z' fill='%2372f2a1'/%3E%3C/svg%3E"; -const visualizerBarCount = 28; - -connect(); -loadState(); -startStatePolling(); - -function connect() { - const protocol = location.protocol === "https:" ? "wss" : "ws"; - socket = new WebSocket(`${protocol}://${location.host}/ws`); - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "state") { - state = message.state; - applyArtworkPrimaryColor(state.current?.track.artwork || ""); - render(); - syncAudio(); - } else if (message.type === "visualizer") { - visualLevels = Array.isArray(message.levels) ? message.levels.slice(0, visualizerBarCount) : []; - } - }); - socket.addEventListener("open", startAdminVisualizer); - socket.addEventListener("error", () => socket.close()); - socket.addEventListener("close", () => setTimeout(connect, 1000)); -} - -function startStatePolling() { - if (statePoll) clearInterval(statePoll); - statePoll = setInterval(() => { - if (!socket || socket.readyState !== WebSocket.OPEN) loadState(); - }, 2000); -} - -async function loadState() { - const response = await fetch("/api/state"); - state = await response.json(); - applyArtworkPrimaryColor(state.current?.track.artwork || ""); - render(); - syncAudio(); -} - -function render() { - if (page === "admin") renderAdmin(); - else if (page === "visualizer") renderVisualizer(); - else renderClient(); -} - -function shell(title, subtitle, content) { - app.innerHTML = ` -
-
- HomeJam - ${title} -
- ${content} -
`; -} - -function renderClient() { - shell( - "Invite", - "", - `
-
-
-

Recherche

- iTunes metadata -
- -
${results.map(resultCard).join("") || empty("Lance une recherche pour proposer un morceau.")}
-
-
-

Queue

${state.queue.length} titre${state.queue.length > 1 ? "s" : ""}
- ${queueList(false)} -
-
` - ); - bindSearch(); - app.querySelectorAll("[data-add]").forEach((button) => { - button.addEventListener("click", async () => { - const track = results[Number(button.dataset.add)]; - await fetch("/api/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(track) }); - }); - }); -} - -function renderAdmin() { - const current = state.current; - const existingAudio = audio; - const shouldKeepAudio = existingAudio && currentAudioId === current?.id; - shell( - "Admin", - "", - `
-
-
-
-

${state.running ? "Jam active" : "Jam arretee"}

-

${escapeHtml(current?.track.title || "Aucun morceau")}

-

${escapeHtml(current ? `${current.track.artist} - ${current.track.album}` : "Le prochain morceau pret partira automatiquement.")}

-
- - - -
- -

-
-
-
-

Downloads

yt-dlp
- ${downloadList()} -
-
-

Queue

${state.queue.length} titre${state.queue.length > 1 ? "s" : ""}
- ${queueList(true)} -
-
` - ); - const renderedAudio = app.querySelector("#player"); - if (shouldKeepAudio) { - renderedAudio.replaceWith(existingAudio); - audio = existingAudio; - } else { - audio = renderedAudio; - audio.addEventListener("ended", () => fetch("/api/player/ended", { method: "POST" })); - } - app.querySelector("#toggleJam").addEventListener("click", () => { - fetch(`/api/jam/${state.running ? "stop" : "start"}`, { method: "POST" }); - }); - app.querySelector("#skipTrack").addEventListener("click", () => fetch("/api/player/skip", { method: "POST" })); - app.querySelector("#clearQueue").addEventListener("click", () => fetch("/api/queue", { method: "DELETE" })); - app.querySelectorAll("[data-remove]").forEach((button) => button.addEventListener("click", () => fetch(`/api/queue/${button.dataset.remove}`, { method: "DELETE" }))); - audio.addEventListener("play", startAdminVisualizer); -} - -function renderVisualizer() { - const current = state.current; - const track = current?.track; - shell( - "Visualizer", - "", - `
-
-
- ${track ? `Pochette de ${escapeHtml(track.album)}` : -
-
-

${escapeHtml(track?.title || "En attente")}

-
- ${escapeHtml(track?.artist || "HomeJam")} - ${escapeHtml(track?.album || "En attente")} -
-
- ${waveform()} -
- -
` - ); - drawVisualizer(); -} - -function waveform() { - return ``; -} - -function visualQueueList() { - const items = state.queue; - if (!items.length) return empty("Aucun morceau en attente."); - return `
${items.map((item, index) => `
- ${String(index + 1).padStart(2, "0")} - -
- ${escapeHtml(item.track.title)} - ${escapeHtml(item.track.artist)} -
-
`).join("")}
`; -} - -function startAdminVisualizer() { - if (page !== "admin" || !audio) return; - if (!audio.currentSrc || audio.paused) return; - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) return; - - if (!adminAudioContext) { - adminAudioContext = new AudioContextClass(); - adminAnalyser = adminAudioContext.createAnalyser(); - adminAnalyser.fftSize = 128; - adminAnalyser.smoothingTimeConstant = 0.74; - adminData = new Uint8Array(adminAnalyser.frequencyBinCount); - } - - if (adminSourceAudio !== audio) { - const source = adminAudioContext.createMediaElementSource(audio); - source.connect(adminAnalyser); - adminAnalyser.connect(adminAudioContext.destination); - adminSourceAudio = audio; - } - - if (adminAudioContext.state === "suspended") adminAudioContext.resume().catch(() => undefined); - broadcastVisualizer(); -} - -function broadcastVisualizer() { - if (adminBroadcastFrame) cancelAnimationFrame(adminBroadcastFrame); - const send = () => { - if (adminAnalyser && adminData && socket?.readyState === WebSocket.OPEN) { - adminAnalyser.getByteFrequencyData(adminData); - const levels = Array.from({ length: visualizerBarCount }, (_, index) => { - const start = Math.floor(index * adminData.length / visualizerBarCount); - const end = Math.max(start + 1, Math.floor((index + 1) * adminData.length / visualizerBarCount)); - let total = 0; - for (let cursor = start; cursor < end; cursor += 1) total += adminData[cursor]; - return Math.min(1, total / (end - start) / 255); - }); - socket.send(JSON.stringify({ type: "visualizer", levels })); - } - adminBroadcastFrame = requestAnimationFrame(send); - }; - send(); -} - -function drawVisualizer() { - if (visualFrame) cancelAnimationFrame(visualFrame); - const bars = Array.from(app.querySelectorAll(".wave span")); - if (!bars.length) return; - - const draw = () => { - bars.forEach((bar, index) => { - const level = Math.max(0.06, Number(visualLevels[index] || 0)); - bar.style.transform = `scaleY(${level})`; - bar.style.opacity = String(0.34 + level * 0.66); - }); - visualFrame = requestAnimationFrame(draw); - }; - draw(); -} - -function applyArtworkPrimaryColor(artwork) { - if (!primaryColorFromArtwork || artwork === primaryArtwork) return; - primaryArtwork = artwork; - if (!artwork) return; - getDominantColor(artwork).then((color) => { - if (!color || artwork !== primaryArtwork) return; - document.body.style.setProperty("--primary-color", color.hex); - document.body.style.setProperty("--primary-color-rgb", `${color.red}, ${color.green}, ${color.blue}`); - }); -} - -function getDominantColor(source) { - return new Promise((resolve) => { - const image = new Image(); - image.crossOrigin = "anonymous"; - image.onload = () => resolve(extractDominantColor(image)); - image.onerror = () => resolve(null); - image.src = source; - }); -} - -function extractDominantColor(image) { - const canvas = document.createElement("canvas"); - const size = 48; - canvas.width = size; - canvas.height = size; - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) return null; - let pixels; - try { - context.drawImage(image, 0, 0, size, size); - pixels = context.getImageData(0, 0, size, size).data; - } catch { - return null; - } - const buckets = new Map(); - - for (let index = 0; index < pixels.length; index += 16) { - const red = pixels[index]; - const green = pixels[index + 1]; - const blue = pixels[index + 2]; - const alpha = pixels[index + 3]; - if (alpha < 180) continue; - - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const saturation = max === 0 ? 0 : (max - min) / max; - const lightness = (max + min) / 510; - if (saturation < 0.18 || lightness < 0.16 || lightness > 0.88) continue; - - const key = `${Math.round(red / 24)},${Math.round(green / 24)},${Math.round(blue / 24)}`; - const bucket = buckets.get(key) || { red: 0, green: 0, blue: 0, score: 0, count: 0 }; - const score = 1 + saturation * 2; - bucket.red += red; - bucket.green += green; - bucket.blue += blue; - bucket.score += score; - bucket.count += 1; - buckets.set(key, bucket); - } - - const best = Array.from(buckets.values()).sort((left, right) => right.score - left.score)[0]; - if (!best) return null; - const color = brightenForVisibility( - Math.round(best.red / best.count), - Math.round(best.green / best.count), - Math.round(best.blue / best.count) - ); - const { red, green, blue } = color; - return { red, green, blue, hex: rgbToHex(red, green, blue) }; -} - -function brightenForVisibility(red, green, blue) { - let color = { red, green, blue }; - while (relativeLuminance(color.red, color.green, color.blue) < 0.42) { - color = { - red: mixChannel(color.red, 255, 0.22), - green: mixChannel(color.green, 255, 0.22), - blue: mixChannel(color.blue, 255, 0.22), - }; - } - return color; -} - -function mixChannel(value, target, amount) { - return Math.round(value + (target - value) * amount); -} - -function relativeLuminance(red, green, blue) { - return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; -} - -function rgbToHex(red, green, blue) { - return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`; -} - -function bindSearch() { - const form = app.querySelector("#searchForm"); - const input = app.querySelector("#searchInput"); - form.addEventListener("submit", async (event) => { - event.preventDefault(); - query = input.value.trim(); - if (query.length < 2) return; - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); - results = await response.json(); - render(); - }); -} - -function syncAudio() { - if (page !== "admin" || !audio) return; - const current = state.current; - if (!current?.track.localPath) { - currentAudioId = null; - audio.removeAttribute("src"); - return; - } - if (currentAudioId !== current.id) { - currentAudioId = current.id; - audio.src = current.track.localPath; - audio.load(); - } - if (state.running) { - audio.play().then(startAdminVisualizer).catch(() => { - const hint = app.querySelector("#autoplayHint"); - if (hint) hint.textContent = "Le navigateur bloque le premier demarrage automatique: autorise l'audio pour cette page, puis l'enchainement sera automatique."; - }); - } else { - audio.pause(); - } -} - -function resultCard(track, index) { - return `
- -
-

${escapeHtml(track.title)}

-

${escapeHtml(track.artist)} - ${escapeHtml(track.album)}

-
- -
`; -} - -function queueList(admin) { - const items = state.queue; - if (!items.length) return empty("Aucun morceau en attente."); - return `
${items.map((item) => `
- -
- ${escapeHtml(item.track.title)} - ${escapeHtml(item.track.artist)} - ${label(item)} -
- ${admin ? `` : ""} -
`).join("")}
`; -} - -function downloadList() { - const items = state.queue.filter((item) => item.status === "downloading" || item.status === "failed"); - if (!items.length) return empty("Aucun telechargement actif."); - return `
${items.map((item) => `
-
${escapeHtml(item.track.title)}${label(item)}
-
- ${item.error ? `

${escapeHtml(item.error)}

` : ""} -
`).join("")}
`; -} - -function label(item) { - if (item.status === "downloading") return `telechargement ${Math.round(item.progress)}%`; - if (item.status === "ready") return "pret"; - if (item.status === "playing") return "en lecture"; - if (item.status === "failed") return "echec"; - return "en file"; -} - -function empty(text) { - return `

${text}

`; -} - -function escapeHtml(value) { - return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[char]); -} diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 50e7f2e..7a81d8e 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,5 +1,222 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Shell } from "../../../lib/Shell"; +import { QueueList } from "../../../lib/QueueList"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder, visualizerBarCount } from "../../../lib/constants"; +import { label } from "../../../lib/helpers"; + +type WebkitWindow = Window & { webkitAudioContext?: typeof AudioContext }; export default function AdminPage() { - return ; + const { state, socketRef } = useJamState(); + const audioRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const sourceAudioRef = useRef(null); + const dataRef = useRef | null>(null); + const broadcastFrameRef = useRef(null); + const currentAudioIdRef = useRef(null); + const [autoplayHint, setAutoplayHint] = useState(""); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + function startVisualizer() { + const audio = audioRef.current; + if (!audio || !audio.currentSrc || audio.paused) return; + const win = window as WebkitWindow; + const AudioContextClass = window.AudioContext || win.webkitAudioContext; + if (!AudioContextClass) return; + + if (!audioContextRef.current) { + const ctx = new AudioContextClass(); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 128; + analyser.smoothingTimeConstant = 0.74; + audioContextRef.current = ctx; + analyserRef.current = analyser; + dataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); + } + + if (sourceAudioRef.current !== audio) { + try { + const source = audioContextRef.current.createMediaElementSource(audio); + source.connect(analyserRef.current!); + analyserRef.current!.connect(audioContextRef.current.destination); + } catch { + // Already connected for this element — ignore. + } + sourceAudioRef.current = audio; + } + + if (audioContextRef.current.state === "suspended") { + audioContextRef.current.resume().catch(() => undefined); + } + + broadcastVisualizer(); + } + + function broadcastVisualizer() { + if (broadcastFrameRef.current) cancelAnimationFrame(broadcastFrameRef.current); + const send = () => { + const analyser = analyserRef.current; + const data = dataRef.current; + const socket = socketRef.current; + if (analyser && data && socket?.readyState === WebSocket.OPEN) { + analyser.getByteFrequencyData(data); + const levels = Array.from({ length: visualizerBarCount }, (_, index) => { + const start = Math.floor((index * data.length) / visualizerBarCount); + const end = Math.max(start + 1, Math.floor(((index + 1) * data.length) / visualizerBarCount)); + let total = 0; + for (let cursor = start; cursor < end; cursor += 1) total += data[cursor]; + return Math.min(1, total / (end - start) / 255); + }); + socket.send(JSON.stringify({ type: "visualizer", levels })); + } + broadcastFrameRef.current = requestAnimationFrame(send); + }; + send(); + } + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const onEnded = () => { + fetch("/api/player/ended", { method: "POST" }); + }; + const onPlay = () => startVisualizer(); + audio.addEventListener("ended", onEnded); + audio.addEventListener("play", onPlay); + return () => { + audio.removeEventListener("ended", onEnded); + audio.removeEventListener("play", onPlay); + if (broadcastFrameRef.current) { + cancelAnimationFrame(broadcastFrameRef.current); + broadcastFrameRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + const current = state.current; + if (!current?.track.localPath) { + currentAudioIdRef.current = null; + audio.removeAttribute("src"); + return; + } + if (currentAudioIdRef.current !== current.id) { + currentAudioIdRef.current = current.id; + audio.src = current.track.localPath; + audio.load(); + } + if (state.running) { + audio + .play() + .then(() => startVisualizer()) + .catch(() => { + setAutoplayHint( + "Le navigateur bloque le premier demarrage automatique: autorise l'audio pour cette page, puis l'enchainement sera automatique.", + ); + }); + } else { + audio.pause(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + const current = state.current; + const downloads = state.queue.filter((item) => item.status === "downloading" || item.status === "failed"); + + return ( + +
+
+
+ +
+
+

{state.running ? "Jam active" : "Jam arretee"}

+

{current?.track.title || "Aucun morceau"}

+

+ {current + ? `${current.track.artist} - ${current.track.album}` + : "Le prochain morceau pret partira automatiquement."} +

+
+ + + +
+
+
+
+
+

Downloads

+ yt-dlp +
+ {downloads.length ? ( +
+ {downloads.map((item) => ( +
+
+ {item.track.title} + {label(item)} +
+
+ +
+ {item.error ?

{item.error}

: null} +
+ ))} +
+ ) : ( +

Aucun telechargement actif.

+ )} +
+
+
+

Queue

+ + {state.queue.length} titre{state.queue.length > 1 ? "s" : ""} + +
+ { + fetch(`/api/queue/${id}`, { method: "DELETE" }); + }} + /> +
+
+
+ ); } diff --git a/src/app/(client)/client/page.tsx b/src/app/(client)/client/page.tsx index f23349a..dbcac7e 100644 --- a/src/app/(client)/client/page.tsx +++ b/src/app/(client)/client/page.tsx @@ -1,5 +1,83 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useState, type FormEvent } from "react"; +import { Shell } from "../../../lib/Shell"; +import { QueueList } from "../../../lib/QueueList"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder } from "../../../lib/constants"; +import type { Track } from "../../../lib/types"; export default function ClientPage() { - return ; + const { state } = useJamState(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length < 2) return; + const response = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + setResults(await response.json()); + } + + async function addTrack(track: Track) { + await fetch("/api/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(track), + }); + } + + return ( + +
+
+
+

Recherche

+ iTunes metadata +
+
+ setQuery(event.target.value)} + placeholder="Titre, artiste, album" + autoComplete="off" + /> + +
+
+ {results.length ? ( + results.map((track) => ( +
+ +
+

{track.title}

+

+ {track.artist} - {track.album} +

+
+ +
+ )) + ) : ( +

Lance une recherche pour proposer un morceau.

+ )} +
+
+
+
+

Queue

+ + {state.queue.length} titre{state.queue.length > 1 ? "s" : ""} + +
+ +
+
+
+ ); } diff --git a/src/app/(visualizer)/visualizer/page.tsx b/src/app/(visualizer)/visualizer/page.tsx index 46eb97b..1cd862c 100644 --- a/src/app/(visualizer)/visualizer/page.tsx +++ b/src/app/(visualizer)/visualizer/page.tsx @@ -1,5 +1,93 @@ -import { HomeJamPage } from "../../page-shell"; +"use client"; + +import { useEffect, useRef, type CSSProperties } from "react"; +import { Shell } from "../../../lib/Shell"; +import { useJamState } from "../../../lib/useJamState"; +import { usePrimaryArtworkColor } from "../../../lib/usePrimaryColor"; +import { placeholder, visualizerBarCount } from "../../../lib/constants"; export default function VisualizerPage() { - return ; + const levelsRef = useRef([]); + const waveRef = useRef(null); + const frameRef = useRef(null); + + const { state } = useJamState({ + onVisualizerMessage: (levels) => { + levelsRef.current = levels.slice(0, visualizerBarCount); + }, + }); + + usePrimaryArtworkColor(state.current?.track.artwork || ""); + + useEffect(() => { + const wave = waveRef.current; + if (!wave) return; + const bars = Array.from(wave.querySelectorAll("span")); + if (!bars.length) return; + const draw = () => { + bars.forEach((bar, index) => { + const level = Math.max(0.06, Number(levelsRef.current[index] || 0)); + bar.style.transform = `scaleY(${level})`; + bar.style.opacity = String(0.34 + level * 0.66); + }); + frameRef.current = requestAnimationFrame(draw); + }; + draw(); + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }; + }, []); + + const current = state.current; + const track = current?.track; + const artwork = track?.artwork || placeholder; + const visualStyle = { "--ambient-art": `url('${artwork}')` } as CSSProperties; + const visibleQueue = state.queue; + + return ( + +
+
+
+ {track +
+
+

{track?.title || "En attente"}

+
+ {track?.artist || "HomeJam"} + {track?.album || "En attente"} +
+
+ +
+ +
+
+ ); } diff --git a/public/styles.css b/src/app/globals.css similarity index 100% rename from public/styles.css rename to src/app/globals.css diff --git a/src/app/page-shell.tsx b/src/app/page-shell.tsx index ed14d5b..9de4937 100644 --- a/src/app/page-shell.tsx +++ b/src/app/page-shell.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import "./globals.css"; type Page = "admin" | "client" | "visualizer"; @@ -29,7 +30,6 @@ export function HomeJamDocument({ page, children }: { page: Page; children: Reac {titleForPage(page)} - -
-