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 = `
- `;
-}
-
-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.")}
-
-
-
-
-
-
-
-
-
-
-
- ${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",
- "",
- `
-
-
-

-
-
-
${escapeHtml(track?.title || "En attente")}
-
- ${escapeHtml(track?.artist || "HomeJam")}
- ${escapeHtml(track?.album || "En attente")}
-
-
- ${waveform()}
-
-
- `
- );
- drawVisualizer();
-}
-
-function waveform() {
- return `
${Array.from({ length: visualizerBarCount }, (_, index) => ``).join("")}
`;
-}
-
-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."}
+
+
+
+
+
+
+
+
+ {autoplayHint}
+
+
+
+
+
+ {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