diff --git a/.gitignore b/.gitignore index f81ffb6..e3750bf 100644 --- a/.gitignore +++ b/.gitignore @@ -59,7 +59,6 @@ Thumbs.db *.wav *.m4a *.webm -!docs/demo/media/*.webm temp_audio/ downloads/ diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 919b5a0..0000000 --- a/docs/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# docs/ - -GitHub Pages site, served from `/docs` on `main` at -. - -- `index.html`: landing page. -- `demo/`: an interactive demo, run entirely in the browser. - - `index.html` + `discord.js`: a simulated Discord channel (hand-written). - The visitor sends `!play` with a Spotify playlist, the bot reacts, joins - the voice channel and posts its status panel, edited in place like the - real one; its clip link opens the clip page in the right-hand panel (a - separate view on phones). A short guided tour can be skipped. Commands: - `!play`, `!next`, `!pause`, `!resume`, `!seek`, `!crossfade`, `!list`, - `!stop`, `!reset`, `!theme`, `!help`, with the bot's English messages. - - `backend.js`: the fake bot (one guild, one session): playback, queue, - loading progress, the panel text and the web API, mirroring `djsnake/`. - The wrapper creates it as `DJDemoBackend.shared`; the iframe uses it - through `window.parent`, so web actions update the Discord panel and - Discord commands update the page (the wrapper fires `visibilitychange` in - the iframe so the page polls at once). - - `player.html`: the real clip page (`web/index.html`) with `backend.js` and - `shim.js`, which replaces `window.fetch` for `/api/{sid}/state`, - `/control` and `/theme`. Opened on its own it runs a standalone session - that is always playing. `?edit=demo` opens the theme editor (saving only - updates the in-memory theme; logos are refused). It is generated, do not - edit it: after changing `web/index.html` (or `shim.js`), run - `python3 docs/demo/build.py` and commit. - - `media/*.webm` are 25 s excerpts of real CC-BY films (with `thumb*.jpg`), rebuilt by - `make-media.sh` (needs the source videos, ffmpeg or the `dj-snake` image). -- `social-preview.png`: 1280x640 image to upload by hand in the repository - settings (Social preview). -- `.nojekyll`: tells Pages to serve files as they are. - -The queue data in the demo is fictional; the footage is real (see below). - -To test locally with video seeking, use a server that supports HTTP Range -requests (GitHub Pages does; `python3 -m http.server` does not). - -## Demo footage credits - -The demo and the social preview use 25 s excerpts (cropped to 16:9, resized, no audio) of these films, released under the Creative Commons Attribution licence. Thumbnails are the official YouTube thumbnails. Excerpts: `demo/make-media.sh`. - -- [Caminandes 3: Llamigos](https://www.youtube.com/watch?v=SkVqJ1SGeL0), (c) Blender Institute / Pablo Vazquez, CC BY (YouTube licence field). -- [Cosmos Laundromat - First Cycle](https://www.youtube.com/watch?v=Y-rmzh0PI3c), (c) Blender Foundation, CC BY (YouTube licence field). -- [Big Buck Bunny](https://www.youtube.com/watch?v=aqz-KE-bpKQ), (c) Blender Foundation, CC BY (YouTube licence field). -- [Tears of Steel](https://www.youtube.com/watch?v=R6MlUcmOul8), (c) Blender Foundation, CC BY 3.0 (stated in the video description, tearsofsteel.org). -- [Sintel](https://www.youtube.com/watch?v=eRsGyueVLvQ), (c) Blender Foundation, CC BY 3.0 (durian.blender.org/sharing). - -Licence: . The films are not endorsed by djsnake and djsnake is not endorsed by Blender. diff --git a/docs/demo/backend.js b/docs/demo/backend.js deleted file mode 100644 index b8f11dc..0000000 --- a/docs/demo/backend.js +++ /dev/null @@ -1,755 +0,0 @@ -/* DJ Snake demo backend. A fake bot that runs in the browser: one guild, one - * player, one web session. It mirrors djsnake/ (controls.py, web.py, - * sessions.py render_panel, playback.py loading lines) closely enough for the - * demo, with fictional data and the CC-BY clips in media/ (credits: - * docs/README.md). - * - * The Discord wrapper (index.html + discord.js) creates one instance and the - * clip page iframe (player.html + shim.js) reaches it through - * window.parent.DJDemoBackend.shared. player.html opened on its own creates a - * standalone instance that is always playing (the old demo). */ -(() => { - "use strict"; - - // Real Creative Commons Attribution films (credits: docs/README.md). Each - // clip is a 25 s excerpt of the YouTube video; titles are the YouTube titles. - const CATALOG = [ - ["Caminandes 3: Llamigos", "SkVqJ1SGeL0"], - ["Cosmos Laundromat – First Cycle", "Y-rmzh0PI3c"], - ["Big Buck Bunny", "aqz-KE-bpKQ"], - ["Tears of Steel", "R6MlUcmOul8"], - ["Sintel", "eRsGyueVLvQ"], - ]; - // Directors of the films, as the "artist" in the fake stats. - const ARTISTS = ["Pablo Vazquez", "Mathieu Auvray", "Sacha Goedegebure", "Ian Hubert", "Colin Levy"]; - const DURATION = 25, START = 1, END = 24; - // Lyrics shown by the page's lyrics panel, per catalog entry (made-up - // placeholder lines, in file time: the clips play from START to END). - // Synced as [seconds, line] (an empty line is an instrumental break), a - // string for plain (unsynced) lyrics, null for "No lyrics found". - const LYRICS = [ - [[1.5, "Up on the ridge where the cold wind blows"], [4.5, "Three of us waiting for the berry to fall"], [7.5, "Hold the rope, don't look below"], - [10.5, ""], [12.5, "One small step and a long way down"], [15.5, "Laughing all the way to the snow"], [18.5, "We'll get it next time, we'll get it all"], [21.5, "Llamas on the mountain, standing tall"]], - [[1.5, "Round and round in the laundromat light"], [4.5, "A sheep on a cliff at the edge of the night"], [7.5, "Sign on the dotted line, change your life"], - [10.5, "Every cycle starts again"], [13.5, ""], [15.5, "Spin me out of this skin"], [18.5, "Wash me up on another shore"], [21.5, "First cycle, never the last"]], - "Morning in the meadow, the dew is on the grass\n" + - "A big old rabbit yawning while the butterflies pass\n\n" + - "Three little troublemakers hiding in the tree\n" + - "Should have left the bunny be\n\n" + - "(These lines are placeholder text for the demo.)", - [[1.5, "Rain on the bridge in the Amsterdam dark"], [4.5, "Steel in the sky and a spark in the heart"], [7.5, "Run the old scene back, frame by frame"], - [10.5, "Change the memory, keep the name"], [13.5, "Tears of steel won't rust tonight"], [16.5, ""], [18.5, "One more take before the light"], [21.5, "Fade to black, then fade to white"]], - null, - ]; - const BASE_URL = "https://dj.example.com"; // PUBLIC_BASE_URL (fictional) - const PREFIX = "!"; - const PRESETS = ["arcade", "minimal", "night", "vinyl", "club", "terminal", "space", "cobalt"]; - const SKINS = PRESETS.concat(["custom"]); - const PANEL_SHORT = 100; - - // code -> [HTTP status, message], as ControlError. - const ERRORS = { - not_in_voice: [409, "I'm not in a voice channel."], - nothing_playing: [409, "No music is currently playing."], - track_not_found: [404, "That track is not in the queue."], - bad_index: [400, "Invalid position."], - bad_loop: [400, "Loop mode must be off, track or queue."], - nothing_to_shuffle: [409, "Not enough queued tracks to shuffle."], - bad_volume: [400, "Volume must be a whole number from 0 to 150."], - bad_filter: [400, "Unknown filter, or an equaliser band outside -12 to 12 dB."], - bad_url: [400, - "Only YouTube, SoundCloud and Bandcamp links (track or playlist), Twitch channels, internet radio streams (.mp3, .aac, .ogg, .m3u8, .m3u, .pls or a /stream mount point) and Spotify, Deezer and Apple Music (track, album or playlist) links are accepted."], - live_no_seek: [409, "A live stream has no position to seek to."], - spotify_short_link: [400, - "Short spotify.link links are not supported: open it and copy the open.spotify.com link."], - bad_playlist_name: [400, "Invalid playlist name: use up to 32 letters, digits, spaces or . ' & ! + ( ) -"], - playlist_not_found: [404, "No saved playlist has that name."], - playlist_exists: [409, - "A playlist with that name already exists. Only its creator or a member with Manage Server can replace it."], - playlist_empty: [400, "There is nothing to save: no track is queued."], - }; - class ControlError extends Error { - constructor(code) { - super(code); - this.code = code; - [this.status, this.message] = ERRORS[code]; - } - } - - const short = (t, n) => { t = String(t); n = n || PANEL_SHORT; return t.length <= n ? t : t.slice(0, n - 1) + "…"; }; - - // Link parsing, as spotify.check_url() and media.parse_youtube_url(). - const URL_LIKE = /^([a-z][a-z0-9+.-]*:|www\.|[\w-]+(\.[\w-]+)+(\/|$))/i; - function parseLink(raw) { - const url = String(raw || "").trim(); - // Anything that is not a link is a text search (media.looks_like_url()). - if (url && url.length <= 200 && !URL_LIKE.test(url.split(" ")[0])) return { kind: "search", text: url }; - let m = /^spotify:(playlist|album|track):([A-Za-z0-9]{22})$/.exec(url); - if (m) return { kind: "spotify", type: m[1], id: m[2] }; - let u; - try { u = new URL(url); } catch (e) { throw new ControlError("bad_url"); } - if (!/^https?:$/.test(u.protocol)) throw new ControlError("bad_url"); - const host = u.hostname.toLowerCase(); - if (host === "spotify.link" || host.endsWith(".spotify.link")) throw new ControlError("spotify_short_link"); - if (host === "open.spotify.com" || host === "play.spotify.com") { - m = /^(?:\/intl-[a-z]{2}(?:-[a-z]{2})?)?(?:\/embed)?\/(playlist|album|track)\/([A-Za-z0-9]{22})\/?$/i.exec(u.pathname); - if (!m) throw new ControlError("bad_url"); - return { kind: "spotify", type: m[1].toLowerCase(), id: m[2] }; - } - if (/^(www\.|m\.|music\.)?youtube\.com$/.test(host)) { - const v = u.searchParams.get("v"), list = u.searchParams.get("list"); - if (v && /^[\w-]{11}$/.test(v)) return { kind: "youtube", video: v, list: list || null }; - if (list) return { kind: "youtube", video: null, list }; - throw new ControlError("bad_url"); - } - if (host === "youtu.be") { - const v = u.pathname.slice(1); - if (/^[\w-]{11}$/.test(v)) return { kind: "youtube", video: v, list: null }; - } - // Live streams (media.parse_media_url's Twitch channels, parse_radio_url). - m = /^\/([A-Za-z0-9_]{3,25})\/?$/.exec(u.pathname); - if (/^(www\.|m\.)?twitch\.tv$/.test(host) && m) return { kind: "live", source: "twitch", title: m[1] }; - const seg = u.pathname.replace(/\/+$/, "").split("/").pop(); - if (/([-_.](mp3|aac|aacp|ogg|oga|opus|flac|m3u8?|pls)|^(stream|listen|live|;))$/i.test(seg)) { - return { kind: "live", source: "radio", title: host }; - } - throw new ControlError("bad_url"); - } - - function create(opts) { - opts = opts || {}; - const standalone = !!opts.standalone; - const base = opts.baseURI || document.baseURI; - // The guild's bot language ("en", "fr", "es" or "de"), as the real state - // sends it; `?lang=de` on the demo's URL shows the German page. - const LANGS = ["en", "fr", "es", "de"]; - let lang = LANGS.includes(opts.lang) ? opts.lang : "en"; - try { const q = new URLSearchParams(location.search).get("lang"); if (LANGS.includes(q)) lang = q; } catch (e) {} - const now = () => performance.now() / 1000; - const media = (k) => new URL("media/clip" + (k + 1) + ".webm", base).href; - const thumb = (k) => new URL("media/thumb" + (k + 1) + ".jpg", base).href; - - let seq = 0, rot = 0; - const mkTrack = (k) => { - k = ((k % CATALOG.length) + CATALOG.length) % CATALOG.length; - seq++; - return { - id: "demo" + seq, title: CATALOG[k][0], duration: DURATION, - thumbnail: thumb(k), url: "https://www.youtube.com/watch?v=" + CATALOG[k][1], - video_url: media(k), k, - }; - }; - const trackFor = (videoId) => { - const k = CATALOG.findIndex((c) => c[1] === videoId); - return k >= 0 ? k : rot++; - }; - - const VOTES_NEEDED = 3; - const S = { - sid: null, wid: null, inVoice: false, current: null, queue: [], history: [], - startedAt: 0, pausedAt: null, pausedTotal: 0, - crossfade: false, loop: "off", volume: 100, autoplay: false, stay: false, epoch: 0, jobs: 0, single: 0, - // Audio filter (filters.py); a speed preset runs the file clock at `rate` - // from `segBase` (the file position where the current segment started). - filter: "off", eq: { low: 0, mid: 0, high: 0 }, rate: 1, segBase: START, - playlists: [], // saved playlists: {name, ids: YouTube video ids} - votes: 0, // skip votes of the current track (4 listeners: 3 needed) - pending: [], // pending_downloads: {key, title (null: link not read), source} - loadLine: null, loadFinal: false, lastAction: null, - theme: { skin: "arcade", base: "night", bg: "#101820", text: "#f2f2f2", accent: "#ffb703", name: "", logo_url: null }, - }; - - // ---- events: {type: "change"} on any state change, "voice" on join/leave, - // "panel-post" when the bot posts a fresh status panel, "web" for a web control. - const listeners = new Set(); - const emit = (type, extra) => { - const ev = Object.assign({ type }, extra || {}); - listeners.forEach((fn) => { try { fn(ev); } catch (e) { console.error(e); } }); - }; - const changed = () => emit("change"); - const on = (fn) => { listeners.add(fn); return () => listeners.delete(fn); }; - - const paused = () => S.pausedAt !== null; - const FILTERS = ["off", "bassboost", "nightcore", "vaporwave", "8d", "karaoke", "soft", "eq"]; - const FILTER_NAMES = { off: "off", bassboost: "Bass boost", nightcore: "Nightcore (1.25× speed)", vaporwave: "Vaporwave (0.8× speed)", "8d": "8D", karaoke: "Karaoke", soft: "Soft", eq: "Equaliser" }; - const rateOf = (f) => ({ nightcore: 1.25, vaporwave: 0.8 })[f] || 1; - const sgn = (v) => (v >= 0 ? "+" : "") + v; - const filterLabel = () => S.filter === "eq" - ? `Equaliser (low ${sgn(S.eq.low)}, mid ${sgn(S.eq.mid)}, high ${sgn(S.eq.high)} dB)` - : FILTER_NAMES[S.filter]; - const elapsed = () => { - const raw = START + Math.max(0, (paused() ? S.pausedAt : now()) - S.startedAt - S.pausedTotal); - return S.segBase + (raw - S.segBase) * S.rate; - }; - function play(t) { clearIdle(); S.votes = 0; S.current = t; S.startedAt = now(); S.pausedAt = null; S.pausedTotal = 0; S.segBase = START; S.rate = rateOf(S.filter); } - - function setAction(text) { - S.lastAction = text; - if (S.loadFinal) { S.loadLine = null; S.loadFinal = false; } - changed(); - } - function progress(text, final) { S.loadLine = text; S.loadFinal = !!final; changed(); } - - // Idle linger (playback.schedule_idle_disconnect): once the queue runs - // out the bot stays in voice for a while, so a late add plays at once. - // Shorter than the bot's default 5 min. - const IDLE_MS = 60000; - let idleTimer = null; - function clearIdle() { if (idleTimer) clearTimeout(idleTimer); idleTimer = null; } - function startIdle() { - clearIdle(); - S.current = null; - S.pausedAt = null; - if (S.stay) return; // 24/7 mode: no timer, the bot never leaves - idleTimer = setTimeout(() => { - idleTimer = null; - if (!S.inVoice || S.current || S.queue.length) return; - if (S.jobs || S.pending.length) { startIdle(); changed(); return; } - leaveVoice(); - changed(); - }, IDLE_MS); - } - - function leaveVoice() { - clearIdle(); - S.inVoice = false; - S.current = null; - S.pausedAt = null; - emit("voice", { inVoice: false }); - } - - // ---- playback chain (playback.py _advance) - function advance(action) { - if (action === "previous" && S.history.length) { - S.queue.unshift(S.current); - play(S.history.pop()); - return; - } - if (action === "end" && S.loop === "track" && S.current && !S.current.live) { play(S.current); changed(); return; } - if (S.current) { - S.history.push(S.current); - if (S.loop === "queue") S.queue.push(S.current); // back to the end of the queue - } - S.history = S.history.slice(-5); - if (standalone) while (S.queue.length < 4) S.queue.push(mkTrack(rot++)); - if (S.queue.length) play(S.queue.shift()); - else if (S.autoplay && S.inVoice && S.jobs === 0 && S.current) { - // Autoplay: a related track (here: the next one of the catalog). - const t = mkTrack(rot++); - t.autoplay = true; - play(t); - } else if (S.jobs > 0) S.current = null; // loading: wait for the next track - else startIdle(); // queue ended: linger in voice, then leave - changed(); - } - function addToQueue(t) { - S.queue.push(t); - if (!S.current && S.inVoice) advance(); - changed(); - } - setInterval(() => { if (S.current && !S.current.live && !paused() && elapsed() >= END) advance("end"); }, 250); - - // ---- session and voice - const sidChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - function newSid() { let s = ""; for (let i = 0; i < 22; i++) s += sidChars[Math.floor(Math.random() * 64)]; return s; } - /** Join voice and open (or reuse) the session; posts a fresh panel. */ - function join() { - if (S.inVoice) return false; - S.inVoice = true; - if (!S.sid) { S.sid = newSid(); S.wid = newSid(); } - S.epoch++; - emit("voice", { inVoice: true }); - emit("panel-post"); - changed(); - return true; - } - function openSession() { - if (S.sid) return false; - S.sid = newSid(); S.wid = newSid(); - emit("panel-post"); - changed(); - return true; - } - - // ---- loading (fake downloads with realistic delays) - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const cancelled = (epoch) => epoch !== S.epoch || !S.inVoice; - // The "Downloading" list (player.pending_downloads). - let pkey = 0; - function pend(title, source) { - const key = "dl" + ++pkey; - S.pending.push({ key, title, source: source || "youtube" }); - return key; - } - function unpend(key) { S.pending = S.pending.filter((p) => p.key !== key); } - function namePending(key, title) { S.pending.forEach((p) => { if (p.key === key) p.title = title; }); } - const label = (k, source) => { - k = ((k % CATALOG.length) + CATALOG.length) % CATALOG.length; - return source === "spotify" ? `${ARTISTS[k]} – ${CATALOG[k][0]}` : CATALOG[k][0]; - }; - /** Load a list of catalog indexes one by one, like queue_playlist(). */ - async function loadList(ks, readLine, source, step) { - const epoch = S.epoch, total = ks.length; - let loaded = 0; - const keys = []; - S.jobs++; - try { - if (readLine) { - const link = pend(null, source); - progress(readLine); - try { await wait(1100); } finally { unpend(link); } - } - if (!cancelled(epoch)) ks.forEach((k) => keys.push(pend(label(k, source), source))); - progress(`📥 Loading 0/${total}…`); - for (let i = 0; i < ks.length; i++) { - await wait(step || (loaded === 0 ? 900 : 1400)); - if (cancelled(epoch)) break; - loaded++; - unpend(keys[i]); - addToQueue(mkTrack(ks[i])); - progress(`📥 Loading ${loaded}/${total}…`); - } - } finally { - S.jobs = Math.max(0, S.jobs - 1); - keys.forEach(unpend); - } - if (cancelled(epoch)) { - progress(`⏹️ Loading stopped (${loaded}/${total})`, true); - return; - } - progress(`✅ ${loaded}/${total} tracks loaded`, true); - if (!S.current && !S.queue.length && S.jobs === 0 && !idleTimer) startIdle(); - } - async function loadOne(k, readLine, source) { - const epoch = S.epoch; - // A YouTube link has no title until yt-dlp reads it; a Spotify one does. - const key = pend(source === "spotify" ? label(k, source) : null, source); - try { - if (readLine) { progress(readLine); await wait(900); S.loadLine = null; } - S.single++; changed(); - try { - await wait(500); - if (source !== "spotify") namePending(key, label(k, source)); - await wait(800); - } finally { S.single = Math.max(0, S.single - 1); } - } finally { unpend(key); } - if (cancelled(epoch)) { changed(); return; } - const t = mkTrack(k); - S.queue.push(t); - setAction(`➕ Added: ${short(t.title)}`); - if (!S.current) advance(); - } - // Live items (media.live_track): no file, no duration, never downloaded. - const NOW_ON_AIR = ["Nightdrive – Neon Avenue", "Lumen – Slow Tide", "Kaito – Paper Moons"]; - async function loadLive(link) { - const epoch = S.epoch; - const key = pend(null, link.source === "twitch" ? "twitch" : "youtube"); - try { await wait(900); } finally { unpend(key); } - if (cancelled(epoch)) { changed(); return; } - seq++; - const t = { - id: "demo" + seq, title: link.source === "twitch" ? link.title + " (Twitch)" : link.title, - duration: null, thumbnail: link.source === "twitch" ? thumb(rot % CATALOG.length) : null, - url: null, video_url: null, live: true, radio: link.source === "radio", - }; - S.queue.push(t); - setAction(`➕ Added: ${short(t.title)}`); - if (!S.current) advance(); - } - /** Queue whatever a link points to (after parseLink). */ - function queueLink(link) { - if (link.kind === "live") return loadLive(link); - if (link.kind === "search") return loadOne(rot++, "🔎 Searching YouTube…", "youtube"); - if (link.kind === "spotify") { - if (link.type === "track") return loadOne(rot++, "🔎 Reading the Spotify link…", "spotify"); - const n = link.type === "playlist" ? 5 : 3; - const ks = []; - for (let i = 0; i < n; i++) ks.push(link.type === "playlist" ? i : rot++); - return loadList(ks, "🔎 Reading the Spotify link…", "spotify"); - } - if (link.video) return loadOne(trackFor(link.video), null, "youtube"); - const ks = [rot++, rot++, rot++]; - return loadList(ks, "🔎 Analyzing the link…", "youtube"); - } - - // ---- controls (controls.py), shared by Discord commands and the web page - function requirePlaying() { - if (!S.inVoice) throw new ControlError("not_in_voice"); - if (!S.current) throw new ControlError("nothing_playing"); - } - const findQueued = (id) => { - const i = S.queue.findIndex((t) => t.id === id); - if (i < 0) throw new ControlError("track_not_found"); - return i; - }; - const C = { - next() { requirePlaying(); advance(); }, - // /voteskip by a member without the DJ role: 3 of the 4 listeners skip. - vote() { - requirePlaying(); - S.votes++; - if (S.votes >= VOTES_NEEDED) { const n = S.votes; advance(); return { count: n, needed: VOTES_NEEDED, skipped: true }; } - changed(); - return { count: S.votes, needed: VOTES_NEEDED, skipped: false }; - }, - previous() { - requirePlaying(); - if (elapsed() - START > 5 || !S.history.length) { play(S.current); changed(); return "restart"; } - advance("previous"); return "previous"; - }, - pause() { requirePlaying(); if (paused()) return false; S.pausedAt = now(); changed(); return true; }, - resume() { - requirePlaying(); - if (!paused()) return false; - S.pausedTotal += now() - S.pausedAt; S.pausedAt = null; changed(); return true; - }, - remove(id) { if (!S.inVoice) throw new ControlError("not_in_voice"); const t = S.queue.splice(findQueued(id), 1)[0]; changed(); return t; }, - move(id, index) { - if (!S.inVoice) throw new ControlError("not_in_voice"); - const i = findQueued(id); - const to = Math.max(0, Math.min(S.queue.length - 1, index)); - S.queue.splice(to, 0, S.queue.splice(i, 1)[0]); - changed(); - }, - jump(id) { - requirePlaying(); - const i = findQueued(id); - S.history.push(S.current); S.history = S.history.slice(-5); - play(S.queue.splice(i, 1)[0]); - changed(); - return S.current; - }, - seek(position) { - requirePlaying(); - if (S.current.live) throw new ControlError("live_no_seek"); - const p = Math.max(START, Math.min(position, END - 1)); - const was = paused(); - play(S.current); - S.startedAt = now() - (p - START); - S.segBase = p; - if (was) S.pausedAt = now(); - changed(); - return p; - }, - filter(name, eq) { - if (!FILTERS.includes(name)) throw new ControlError("bad_filter"); - const gains = Object.assign({}, S.eq); - if (eq != null) { - if (typeof eq !== "object" || Array.isArray(eq)) throw new ControlError("bad_filter"); - for (const [k, v] of Object.entries(eq)) { - if (!(k in gains) || !Number.isInteger(v) || v < -12 || v > 12) throw new ControlError("bad_filter"); - gains[k] = v; - } - } - S.filter = name; S.eq = gains; - // The bot restarts the track where it is with the new chain (a seek). - if (S.inVoice && S.current) C.seek(elapsed()); - else changed(); - }, - crossfade(on) { S.crossfade = !!on; changed(); }, - loop(mode) { - const modes = ["off", "track", "queue"]; - if (mode == null) mode = modes[(modes.indexOf(S.loop) + 1) % 3]; - if (!modes.includes(mode)) throw new ControlError("bad_loop"); - S.loop = mode; changed(); - return mode; - }, - shuffle() { - if (!S.inVoice) throw new ControlError("not_in_voice"); - if (S.queue.length < 2) throw new ControlError("nothing_to_shuffle"); - for (let i = S.queue.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [S.queue[i], S.queue[j]] = [S.queue[j], S.queue[i]]; - } - changed(); - }, - volume(n) { - if (!Number.isInteger(n) || n < 0 || n > 150) throw new ControlError("bad_volume"); - S.volume = n; - changed(); - }, - autoplay(on) { S.autoplay = !!on; changed(); }, - stay(on) { S.stay = !!on; if (S.stay) clearIdle(); else if (S.inVoice && !S.current && !S.queue.length) startIdle(); changed(); }, - add(url) { - if (!S.inVoice) throw new ControlError("not_in_voice"); - queueLink(parseLink(url)); - }, - savePlaylist(name) { - name = String(name || "").trim().replace(/\s+/g, " "); - if (!name || name.length > 32 || !/^\w[\w .'&!+()-]*$/.test(name)) throw new ControlError("bad_playlist_name"); - if (S.playlists.some((p) => p.name.toLowerCase() === name.toLowerCase())) throw new ControlError("playlist_exists"); - const ids = (S.current ? [S.current] : []).concat(S.queue).filter((t) => !t.live).map((t) => t.url.split("v=")[1]); - if (!ids.length) throw new ControlError("playlist_empty"); - S.playlists.push({ name, ids }); - return name; - }, - loadPlaylist(name) { - if (!S.inVoice) throw new ControlError("not_in_voice"); - const p = S.playlists.find((x) => x.name.toLowerCase() === String(name).trim().toLowerCase()); - if (!p) throw new ControlError("playlist_not_found"); - loadList(p.ids.map(trackFor), null, "youtube"); - return p.name; - }, - stop() { - S.epoch++; - S.queue = []; S.history = []; S.pending = []; - if (S.stay) { clearIdle(); S.current = null; S.pausedAt = null; } // 24/7: stays - else leaveVoice(); - if (!S.jobs) { S.loadLine = null; S.loadFinal = false; } - setAction("⏹️ Stopped"); - }, - }; - const seekLabel = (applied) => { - const rel = Math.floor(Math.max(0, applied - START)); - return `⏩ Seeked to ${Math.floor(rel / 60)}:${String(rel % 60).padStart(2, "0")}`; - }; - - // ---- status panel (sessions.render_panel) - function renderPanel() { - const lines = []; - // WEB_CONTROL=everyone and no DJ role: both links are public. - if (S.sid) { - lines.push(`📺 Watch and control the clip live: ${BASE_URL}/${S.sid}`); - lines.push(`👀 View-only link: <${BASE_URL}/${S.wid}>`); - } - if (!S.inVoice) lines.push(`💤 Idle — ${PREFIX}play to resume`); - else if (!S.current && idleTimer && !S.queue.length) lines.push("💤 Queue empty — leaving in 1 min unless something is added"); - else if (S.current) lines.push(`🎵 Now playing: **${short(S.current.title)}**` + (paused() ? " ⏸️ paused" : "") + (S.current.autoplay ? " 📻 Autoplay" : "") + (S.current.live ? " 🔴 LIVE" : "")); - else lines.push("🎵 Nothing playing yet"); - if (S.queue.length) { - let line = `⏭️ Up next: ${short(S.queue[0].title)}`; - if (S.queue.length > 1) line += ` and ${S.queue.length - 1} more`; - lines.push(line); - } - if (S.crossfade) lines.push("🔀 Crossfade on"); - if (S.loop !== "off") lines.push(`🔁 Loop: ${S.loop}`); - if (S.volume !== 100) lines.push(`🔊 Volume ${S.volume}%`); - if (S.stay) lines.push("🌙 24/7"); - if (S.autoplay) lines.push("📻 Autoplay on"); - if (S.current && S.votes > 0) lines.push(`🗳️ Skip votes ${S.votes}/${VOTES_NEEDED}`); - if (S.filter !== "off") lines.push(`🎛️ Filter: ${filterLabel()}`); - if (S.loadLine) lines.push(S.loadLine); - else if (S.pending.length > 1) lines.push(`📥 Downloading ${S.pending.length} tracks…`); - else if (S.single || S.pending.length) lines.push("📥 Downloading…"); - if (S.lastAction) lines.push(S.lastAction); - return lines.join("\n"); - } - - // ---- web API (web.py), errors as the real page expects - const pub = (t) => ({ id: t.id, title: t.title, duration: t.duration, thumbnail: t.thumbnail, url: t.url, autoplay: !!t.autoplay, live: !!t.live }); - const nowPlaying = (c) => c.live - ? Object.assign(pub(c), { - video_url: null, audio_only: true, elapsed: elapsed(), start: 0, end: null, - // A radio's ICY StreamTitle changes now and then. - live_title: c.radio ? NOW_ON_AIR[Math.floor(Date.now() / 20000) % NOW_ON_AIR.length] : null, - }) - : Object.assign(pub(c), { video_url: c.video_url, audio_only: false, elapsed: elapsed(), start: START, end: END, live_title: null }); - function state(view) { - if (!S.sid) return { status: 404, body: { active: false } }; - const c = S.inVoice ? S.current : null; - return { - status: 200, - body: { - active: true, in_voice: S.inVoice, guild: "Friday Night", channel: "Lounge", - loading: S.jobs > 0, paused: !!c && paused(), can_previous: !!c, - crossfade: S.crossfade, loop: S.loop, volume: S.volume, autoplay: S.autoplay, stay: S.stay, crossfading: false, - filter: S.filter, eq: Object.assign({}, S.eq), rate: c ? S.rate : rateOf(S.filter), server_time: Date.now() / 1000, - votes: c && S.votes > 0 ? { count: S.votes, needed: VOTES_NEEDED } : null, - viewers: viewers(), lang, - theme: S.theme, - lyrics: true, - can_control: !view, - now_playing: c ? nowPlaying(c) : null, - queue: S.inVoice ? S.queue.map(pub) : [], - downloading: S.inVoice ? S.pending.slice(0, 50).map((p) => Object.assign({}, p)) : [], - downloading_total: S.inVoice ? S.pending.length : 0, - }, - }; - } - // A few people on the page, slowly coming and going. - const viewers = () => 3 + Math.round(1.5 + 1.5 * Math.sin(Date.now() / 47000)); - - // ---- fake listening stats (stats.summary), built from the catalog with a - // fixed seed so every visit shows the same numbers. - function fakeStats(range) { - const days = range === "week" ? 7 : range === "month" ? 30 : 120; - let seed = 1337; - const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); - const tracks = new Map(), artists = new Map(), skips = new Map(); - const heatmap = new Array(168).fill(0); - const nowSec = Date.now() / 1000; - let seconds = 0, count = 0; - const bump = (m, k, n) => m.set(k, (m.get(k) || 0) + (n || 1)); - const plays = days * 9; - for (let i = 0; i < plays; i++) { - // Evenings and weekends are busier (UTC hour-of-week buckets). - const day = Math.floor(rnd() * 7), weekend = day >= 4; - const hour = rnd() < 0.75 ? 18 + Math.floor(rnd() * 6) : (weekend ? 13 : 12) + Math.floor(rnd() * 6); - const k = Math.min(CATALOG.length - 1, Math.floor(Math.pow(rnd(), 1.6) * CATALOG.length)); - const skipped = rnd() < (k === 3 ? 0.35 : 0.08); - heatmap[day * 24 + (hour % 24)]++; - count++; - seconds += skipped ? 12 : 200; - if (skipped) { bump(skips, k); continue; } - bump(tracks, k); - bump(artists, ARTISTS[k]); - } - const top = (m, n) => [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, n); - const worst = top(skips, 1)[0]; - return { - range, - top_tracks: top(tracks, 10).map(([k, n]) => ({ title: CATALOG[k][0], artist: ARTISTS[k], video_id: CATALOG[k][1], plays: n })), - top_artists: top(artists, 10).map(([a, n]) => ({ name: a, plays: n })), - most_skipped: worst ? { title: CATALOG[worst[0]][0], artist: ARTISTS[worst[0]], skips: worst[1] } : null, - totals: { hours: Math.round(seconds / 360) / 10, tracks: count, sessions: Math.round(days * 0.8) }, - heatmap, - peak_listeners: { count: days > 7 ? 9 : 6, at: nowSec - (days > 7 ? 12 : 2) * 86400 - 3 * 3600 }, - }; - } - - const fail = (status, error) => ({ status, body: { ok: false, error } }); - function control(d) { - d = d || {}; - const a = d.action; - if (!S.sid) return fail(404, "Unknown or expired session."); - try { - if (a === "next") { C.next(); setAction("⏭️ Skipped via the web page"); } - else if (a === "previous") setAction(C.previous() === "restart" ? "⏮️ Restarted via the web page" : "⏮️ Previous track via the web page"); - else if (a === "pause") C.pause(); - else if (a === "resume") C.resume(); - else if (a === "remove") setAction(`🗑️ Removed: ${short(C.remove(d.id).title)}`); - else if (a === "move") { - const idx = parseInt(d.index, 10); - if (!(idx >= 0)) return fail(400, "Invalid position."); - C.move(d.id, idx); setAction("🔀 Queue reordered"); - } else if (a === "seek") { - const p = Number(d.position); - if (!isFinite(p) || p < 0) return fail(400, "Invalid position."); - setAction(seekLabel(C.seek(p))); - } else if (a === "jump") setAction(`▶️ Jumped to: ${short(C.jump(d.id).title)}`); - else if (a === "crossfade") { - if (typeof d.enabled !== "boolean") return fail(400, "Invalid request."); - C.crossfade(d.enabled); - } else if (a === "loop") { - if (d.mode != null && !["off", "track", "queue"].includes(d.mode)) return fail(400, ERRORS.bad_loop[1]); - setAction(`🔁 Loop: ${C.loop(d.mode)} via the web page`); - } else if (a === "shuffle") { - C.shuffle(); setAction("🔀 Queue shuffled via the web page"); - } else if (a === "volume") { - C.volume(d.value); - setAction(`🔊 Volume set to ${d.value}% via the web page`); - } else if (a === "autoplay") { - if (typeof d.enabled !== "boolean") return fail(400, "Invalid request."); - C.autoplay(d.enabled); - } else if (a === "filter") { - if (typeof d.preset !== "string") return fail(400, ERRORS.bad_filter[1]); - C.filter(d.preset, d.eq); - setAction(`🎛️ Filter set to ${filterLabel()} via the web page`); - } else if (a === "save_playlist" || a === "load_playlist") { - if (typeof d.name !== "string" || !d.name || d.name.length > 200) return fail(400, "Invalid playlist name."); - if (a === "save_playlist") setAction(`💾 Saved playlist ${C.savePlaylist(d.name)} via the web page`); - else { - setAction(`📥 Loading playlist ${C.loadPlaylist(d.name)} via the web page`); - return { status: 202, body: { ok: true } }; - } - } else if (a === "add") { - if (typeof d.url !== "string") return fail(400, "Missing link."); - C.add(d.url); - emit("web", { action: a }); - return { status: 202, body: { ok: true } }; - } else return fail(400, "Unknown action."); - } catch (e) { - if (e instanceof ControlError) return fail(e.status, e.message); - throw e; - } - emit("web", { action: a }); - return { status: 200, body: { ok: true } }; - } - function setTheme(d) { - d = d || {}; - const col = (v, f) => (/^#[0-9a-fA-F]{6}$/.test(v) ? v : f); - S.theme = { - skin: SKINS.includes(d.skin) ? d.skin : "arcade", - base: PRESETS.includes(d.base) ? d.base : "night", - bg: col(d.bg, S.theme.bg), text: col(d.text, S.theme.text), accent: col(d.accent, S.theme.accent), - name: String(d.name || "").slice(0, 32), logo_url: null, - }; - setAction("🎨 Clip page theme updated via the web page"); - return { status: 200, body: { ok: true, theme: S.theme } }; - } - // GET /api/{sid}/lyrics/{id}: a playing, queued or history track's lyrics. - function lyrics(id) { - if (!S.sid) return { status: 404, body: { active: false } }; - const t = [S.current].concat(S.queue, S.history).find((x) => x && x.id === id); - if (!t) return fail(404, "Unknown track."); - const l = LYRICS[t.k]; - if (Array.isArray(l)) return { status: 200, body: { synced: l.map(([s, line]) => [Math.round(s * 1000), line]), plain: l.map((e) => e[1]).join("\n") } }; - if (typeof l === "string") return { status: 200, body: { synced: null, plain: l } }; - return fail(404, "No lyrics found."); - } - /** Fake HTTP: `route` is state | stats | control | playlists | theme | - * theme/logo | lyrics/{track id}. `view`: the page was opened with the - * view-only link (web.py answers `can_control: false` and refuses control - * and theme routes with 403; the GET routes stay readable). */ - function http(route, method, body, view) { - if (route === "state") return state(view); - if (route.startsWith("lyrics/")) return lyrics(decodeURIComponent(route.slice(7))); - if (view && !["stats", "playlists"].includes(route) && S.sid) { - return fail(403, `This is a view-only link. Ask a DJ for the control link (${PREFIX}weblink in Discord).`); - } - if (route === "stats") { - if (!S.sid) return { status: 404, body: { active: false } }; - const range = body && body.range; - if (!["week", "month", "all"].includes(range)) return fail(400, "Unknown period."); - return { status: 200, body: fakeStats(range) }; - } - if (route === "control") return control(body); - if (route === "playlists") { - if (!S.sid) return { status: 404, body: { active: false } }; - const playlists = S.playlists.map((p) => ({ name: p.name, tracks: p.ids.length, duration: p.ids.length * DURATION })); - return { status: 200, body: { playlists } }; - } - if (!S.sid) return fail(404, "Unknown or expired session."); - if (route === "theme") return setTheme(body); - return fail(400, "Logos are not available in the demo."); - } - - if (standalone) { - S.sid = newSid(); S.wid = newSid(); - S.inVoice = true; - while (S.queue.length < 4) S.queue.push(mkTrack(rot++)); - play(S.queue.shift()); - S.queue.push(mkTrack(rot++)); - } - - return { - /** Standalone demo: a fake playlist that loads slowly, so the page's - * "Downloading" list has something to show. */ - demoLoad(n) { - if (S.jobs || !S.inVoice || S.queue.length > 6) return null; - const ks = []; - for (let i = 0; i < (n || 4); i++) ks.push(rot++); - return loadList(ks, null, ks[0] % 2 ? "spotify" : "youtube", 2600); - }, - on, http, parseLink, queueLink, join, openSession, renderPanel, seekLabel, C, ControlError, - catalog: CATALOG, start: START, prefix: PREFIX, presets: PRESETS, skins: SKINS, baseUrl: BASE_URL, - get sid() { return S.sid; }, - get wid() { return S.wid; }, - get inVoice() { return S.inVoice; }, - get playing() { return S.inVoice && !!S.current && !paused(); }, - get current() { return S.current; }, - get queue() { return S.queue.slice(); }, - get jobs() { return S.jobs; }, - get theme() { return S.theme; }, - get volume() { return S.volume; }, - get autoplay() { return S.autoplay; }, - get stay() { return S.stay; }, - get filter() { return S.filter; }, - filterLabel, - setSkin(skin) { S.theme = Object.assign({}, S.theme, { skin }); changed(); }, - setAction, - }; - } - - window.DJDemoBackend = { create, parseLink, ControlError }; -})(); diff --git a/docs/demo/build.py b/docs/demo/build.py deleted file mode 100644 index 96760d5..0000000 --- a/docs/demo/build.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Regenerate docs/demo/player.html from web/index.html plus the demo shim. - -player.html is the real clip page running on the fake bot in backend.js. The -demo's entry page, index.html (a mock Discord client, hand-written), loads it -in an iframe; opened on its own it plays a standalone session. - -Run from anywhere: python3 docs/demo/build.py -""" - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -SRC = ROOT / "web" / "index.html" -OUT = ROOT / "docs" / "demo" / "player.html" - -RIBBON_CSS = """ - -""" - -RIBBON_HTML = """ -
- Demo: CC BY film excerpts (Blender) - Discord demo - Theme editor - View-only link - Credits -
-""" - - -def main(): - html = SRC.read_text(encoding="utf-8") - # The shim must run before the page's own scripts: put it first in . - head = '\n\n' - assert "" in html and "" in html - html = html.replace("", "\n" + head, 1) - html = html.replace("", RIBBON_CSS + "", 1) - html = html.replace("", RIBBON_HTML + "", 1) - OUT.write_text(html, encoding="utf-8") - print(f"wrote {OUT.relative_to(ROOT)} ({len(html)} bytes)") - - -if __name__ == "__main__": - main() diff --git a/docs/demo/discord.js b/docs/demo/discord.js deleted file mode 100644 index 80d2142..0000000 --- a/docs/demo/discord.js +++ /dev/null @@ -1,548 +0,0 @@ -/* DJ Snake demo: the mock Discord client. It owns the shared fake bot - * (backend.js) that the clip page iframe (player.html + shim.js) also uses, - * answers commands like djsnake/commands.py, keeps the status panel edited in - * place like djsnake/sessions.py, and runs the guided tour. */ -(() => { - "use strict"; - - const B = window.DJDemoBackend.shared = window.DJDemoBackend.create({}); - const $ = (id) => document.getElementById(id); - const later = (ms) => new Promise((r) => setTimeout(r, ms)); - const P = B.prefix; - const PLAY_CMD = P + "play https://open.spotify.com/playlist/0DjSnakeDemoPlaylist00"; - const MOBILE = window.matchMedia("(max-width: 900px)"); - - // ---- people - const PEOPLE = { - you: { name: "you", initials: "YO", color: "#5865f2", nameColor: "#f2f3f5" }, - maya: { name: "Maya", initials: "MA", color: "#c2185b", nameColor: "#f47fff" }, - theo: { name: "Theo", initials: "TH", color: "#00796b", nameColor: "#1abc9c" }, - jun: { name: "Jun", initials: "JU", color: "#b26a00", nameColor: "#f1c40f" }, - bot: { name: "DJ Snake", initials: "🐍", color: "", nameColor: "#2ecc71", bot: true }, - tip: { name: "Demo tip", initials: "💡", color: "", nameColor: "#b5bac1", tip: true }, - }; - function avatar(p, size) { - const d = document.createElement("div"); - d.className = "av" + (size ? " s" + size : "") + (p.bot ? " bot" : "") + (p.tip ? " tip" : ""); - if (p.color) d.style.background = p.color; - d.textContent = p.initials; - d.setAttribute("aria-hidden", "true"); - return d; - } - - // ---- voice channel (sidebar list + compact strip on phones) - const VOICE = ["you", "maya", "theo", "jun"]; - let botJoinedAt = 0; - function renderVoice() { - const members = (B.inVoice ? ["bot"] : []).concat(VOICE); - const speaking = B.playing; - for (const [box, size] of [[$("vlist"), 24], [$("vstrip"), 24]]) { - box.querySelectorAll(".vm").forEach((n) => n.remove()); - for (const key of members) { - const p = PEOPLE[key]; - const li = document.createElement(box.tagName === "UL" ? "li" : "div"); - li.className = "vm" + (key === "bot" && speaking ? " speaking" : "") + (key === "bot" && performance.now() - botJoinedAt < 400 ? " joining" : ""); - li.title = p.name + (key === "bot" && speaking ? " (speaking)" : ""); - li.appendChild(avatar(p, size)); - const n = document.createElement("span"); - n.className = "name"; - n.textContent = p.name; - li.appendChild(n); - if (p.bot) { const t = document.createElement("span"); t.className = "tag"; t.textContent = "Bot"; li.appendChild(t); } - box.appendChild(li); - } - } - } - let lastVoiceSig = ""; - function updateVoice() { - const sig = B.inVoice + "/" + B.playing; - if (sig === lastVoiceSig) return; - lastVoiceSig = sig; - renderVoice(); - } - - // ---- messages - const msgs = $("msgs"); - const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - /** A small subset of Discord markdown: code blocks, inline code, bold, links. */ - function md(text) { - // only hides Discord's link preview: show the bare link. - const parts = String(text).replace(/<(https?:\/\/[^\s>]+)>/g, "$1").split(/```\n?([\s\S]*?)```/); - return parts.map((part, i) => { - if (i % 2) return "
" + esc(part.replace(/\n$/, "")) + "
"; - return part.split(/`([^`]+)`/).map((p, j) => { - if (j % 2) return "" + esc(p) + ""; - let h = esc(p).replace(/\*\*(.+?)\*\*/g, "$1"); - h = h.replace(/https?:\/\/[^\s<]+/g, (u) => { - if (B.wid && u === B.baseUrl + "/" + B.wid) return '' + u + ""; - if (u.startsWith(B.baseUrl + "/")) return '' + u + ""; - return '' + u + ""; - }); - return h; - }).join(""); - }).join(""); - } - const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); - let lastAuthor = null, lastAt = 0; - function nearBottom() { return msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 120; } - function scrollDown(force) { if (force || nearBottom()) msgs.scrollTop = msgs.scrollHeight; } - /** Post a message; returns its element. */ - function post(who, text, opts) { - opts = opts || {}; - const stick = nearBottom(); - const p = PEOPLE[who]; - const el = document.createElement("div"); - const cont = !opts.eph && lastAuthor === who && Date.now() - lastAt < 5 * 60 * 1000; - el.className = "msg" + (cont ? " cont" : "") + (opts.eph ? " eph" : "") + (opts.cls ? " " + opts.cls : ""); - el.appendChild(avatar(p)); - const body = document.createElement("div"); - body.className = "mbody"; - if (!cont) { - const head = document.createElement("div"); - head.className = "mhead"; - head.innerHTML = '' + (p.bot ? 'Bot' : "") + ""; - head.querySelector(".name").textContent = p.name; - head.querySelector(".name").style.color = p.nameColor; - head.querySelector("time").textContent = (opts.time || "Today at " + clock()); - body.appendChild(head); - } - const t = document.createElement("div"); - t.className = "mtext"; - t.innerHTML = md(text); - body.appendChild(t); - if (opts.eph) { - const f = document.createElement("div"); - f.className = "foot"; - f.textContent = "👁 Only you can see this · demo hint"; - body.appendChild(f); - } - const r = document.createElement("div"); - r.className = "reacts"; - body.appendChild(r); - el.appendChild(body); - msgs.appendChild(el); - lastAuthor = opts.eph ? null : who; - lastAt = Date.now(); - scrollDown(stick || who === "you"); - return el; - } - function react(el, emoji) { - const r = document.createElement("span"); - r.className = "react"; - r.title = "DJ Snake reacted"; - r.innerHTML = esc(emoji || "✅") + "1"; - el.querySelector(".reacts").appendChild(r); - scrollDown(); - } - async function botSay(text) { - $("typing").innerHTML = "DJ Snake is typing…"; - await later(450); - $("typing").textContent = ""; - return post("bot", text); - } - const tip = (text) => post("tip", text, { eph: true }); - - // ---- status panel: one message, edited in place (sessions.refresh_panel) - let panelEl = null, panelText = "", panelTimer = null, panelLastEdit = 0; - const PANEL_MIN_INTERVAL = 900; // ms; the bot waits 1.5 s between edits - function postPanel() { - const old = panelEl; - clearTimeout(panelTimer); panelTimer = null; - panelText = B.renderPanel(); - lastAuthor = null; // a fresh panel always shows its header - panelEl = post("bot", panelText, { cls: "panel" }); - panelLastEdit = performance.now(); - if (old) old.remove(); - tourPanelPosted(); - } - function writePanel() { - panelTimer = null; - if (!panelEl) return; - const text = B.renderPanel(); - if (text === panelText) return; - const stick = nearBottom(); - panelText = text; - panelLastEdit = performance.now(); - const t = panelEl.querySelector(".mtext"); - t.innerHTML = md(text) + '(edited)'; - panelEl.classList.add("flash"); - requestAnimationFrame(() => requestAnimationFrame(() => panelEl && panelEl.classList.remove("flash"))); - if (stick) scrollDown(true); - } - function refreshPanel() { - if (!panelEl || panelTimer) return; - const wait = Math.max(120, panelLastEdit + PANEL_MIN_INTERVAL - performance.now()); - panelTimer = setTimeout(writePanel, wait); - } - - // ---- the clip page iframe - let frame = null, frameNotify = 0; - function notifyFrame() { - // The page gets pushed states (the shim's fake EventSource); should it - // fall back to polling, it polls at once on visibilitychange, so a - // Discord command still shows up on the page right away. - if (!frame || frameNotify) return; - frameNotify = setTimeout(() => { - frameNotify = 0; - try { frame.contentDocument.dispatchEvent(new Event("visibilitychange")); } catch (e) {} - }, 150); - } - function showView(web) { - document.body.classList.toggle("view-web", !!web); - tourPlace(); - } - let frameView = false; // The frame shows the view-only link (player.html?view) - function openClip(query) { - if (!B.sid) return; - const view = query === "?view"; - $("urltext").innerHTML = 'dj.example.com/' + esc(view ? B.wid : B.sid) + (query && !view ? esc(query) : ""); - if (!frame || query !== undefined) { - frameView = view; - if (frame) frame.remove(); - frame = document.createElement("iframe"); - frame.title = "DJ Snake clip page"; - frame.setAttribute("allow", "fullscreen; autoplay"); - frame.src = "player.html" + (query || ""); - frame.addEventListener("load", tourFrameLoaded, { once: true }); - $("ph").hidden = true; - $("wbody").appendChild(frame); - } - document.body.classList.add("has-web"); - showView(true); - } - msgs.addEventListener("click", (e) => { - const a = e.target.closest("a[data-clip]"); - if (!a) return; - e.preventDefault(); - if (a.dataset.clip === "view") openClip("?view"); - else openClip(frameView ? "" : undefined); - }); - $("back").addEventListener("click", () => showView(false)); - $("toweb").addEventListener("click", () => showView(true)); - $("themelink").addEventListener("click", (e) => { - e.preventDefault(); - B.openSession(); // as !theme edit: opens a session (and its panel) if none - openClip("?edit=demo"); - }); - - // ---- backend events - B.on((ev) => { - if (ev.type === "panel-post") postPanel(); - else if (ev.type === "voice") { - if (ev.inVoice) { - botJoinedAt = performance.now(); - if (tour.step === "free") $("input").placeholder = "Message #bot-commands"; - } - lastVoiceSig = ""; - updateVoice(); - } - else if (ev.type === "web") tourDone("web"); - if (ev.type === "change" || ev.type === "voice") { refreshPanel(); updateVoice(); notifyFrame(); } - }); - - // ---- commands (commands.py) - function parsePosition(text) { - const parts = String(text).split(":").map(Number); - if (!parts.length || parts.length > 3 || parts.some((p) => !isFinite(p) || p < 0) || String(text).trim() === "") return null; - return parts.reduce((s, p) => s * 60 + p, 0); - } - /** _run_command_control: run a control, answer its error or react. */ - async function runControl(msg, fn) { - if (!B.sid) { await botSay("❌ No music is currently playing."); return false; } - let result; - try { result = fn(); } catch (e) { - if (e instanceof B.ControlError) { await botSay("❌ " + e.message); return false; } - throw e; - } - await later(250); - react(msg); - return { result }; - } - // !help as HelpCommand sends it (help.title, then help.cmd. in HELP_ORDER). - const HELP = ["🎶 **DJ Snake commands**", - `\`${P}play \`: play a YouTube, SoundCloud, Bandcamp, Spotify, Deezer or Apple Music link, or the best YouTube match for some text`, - `\`${P}list\`: show the queue`, - `\`${P}next\`: skip to the next track`, - `\`${P}voteskip\`: vote to skip the current track (\`threshold <1-100>\` sets the share of votes needed, DJs only)`, - `\`${P}pause\`: pause playback`, - `\`${P}resume\`: resume playback`, - `\`${P}seek \`: jump to a position (seconds or mm:ss)`, - `\`${P}crossfade on|off\`: blend each track into the next`, - `\`${P}stop\`: stop, clear the queue and leave the voice channel`, - `\`${P}reset\`: reset the voice connection`, - `\`${P}loop [off|track|queue]\`: repeat the track or the queue (empty cycles)`, - `\`${P}shuffle\`: shuffle the upcoming queue once`, - `\`${P}volume [0-150]\`: show or set the server's volume`, - `\`${P}autoplay [on|off]\`: keep playing related tracks when the queue runs out`, - `\`${P}stay [on|off]\`: 24/7 mode, stay in the voice channel even when idle`, - `\`${P}filter [preset]\`: show or set the audio filter (off, bassboost, nightcore, vaporwave, 8d, karaoke, soft, eq low mid high)`, - `\`${P}playlist save|add|load|show|delete \`, \`${P}playlist list\`: saved playlists of the server`, - `\`${P}theme [preset|reset|edit]\`: clip page theme (Manage Server)`, - `\`${P}djrole [role|off]\`: the role that may control the music (Manage Server)`, - `\`${P}weblink\`: get the clip page links by direct message`, - `\`${P}language [code|reset]\`: language of the bot and the clip page (Manage Server)`, - `\`${P}help\`: show this list`, - ].join("\n"); - - const COMMANDS = { - async play(msg, arg) { - if (!arg) return botSay(`❌ Usage: \`${P}play \``); - let link; - try { link = B.parseLink(arg); } catch (e) { - if (e instanceof B.ControlError) return botSay("❌ " + e.message); - throw e; - } - tourDone("send"); - await later(300); - react(msg); - if (!B.inVoice) { await later(600); B.join(); } - B.queueLink(link); - }, - async next(msg) { if (await runControl(msg, () => B.C.next())) B.setAction("⏭️ Skipped"); }, - async voteskip(msg) { - let r = null; - if (!(await runControl(msg, () => { r = B.C.vote(); }))) return; - botSay(r.skipped ? `⏭️ Skipped by vote (${r.count}/${r.needed})` : `🗳️ Vote to skip: **${r.count}/${r.needed}** votes`); - refreshPanel(); - }, - async pause(msg) { if (await runControl(msg, () => B.C.pause())) refreshPanel(); }, - async resume(msg) { if (await runControl(msg, () => B.C.resume())) refreshPanel(); }, - async seek(msg, arg) { - if (!arg) return botSay(`❌ Missing argument. Try \`${P}help\`.`); - const s = parsePosition(arg); - if (s === null) return botSay("❌ Invalid position. Use seconds or mm:ss."); - const r = await runControl(msg, () => B.C.seek(B.start + s)); - if (r) B.setAction(B.seekLabel(r.result)); - }, - async crossfade(msg, arg) { - const mode = (arg || "").toLowerCase(); - if (mode !== "on" && mode !== "off") return botSay(`❌ Usage: \`${P}crossfade on|off\``); - B.C.crossfade(mode === "on"); - await later(250); - react(msg); - }, - async loop(msg, arg) { - const mode = (arg || "").toLowerCase() || null; - if (mode !== null && !["off", "track", "queue"].includes(mode)) return botSay(`❌ Usage: \`${P}loop [off|track|queue]\``); - B.C.loop(mode); - await later(250); - react(msg); - }, - async shuffle(msg) { - if (await runControl(msg, () => B.C.shuffle())) refreshPanel(); - }, - async volume(msg, arg) { - if (!arg) { - return botSay(`🔊 Volume: **${B.volume}%**\n\`${P}volume <0-150>\` changes it (100 is the normal level).`); - } - const n = /^\d+%?$/.test(arg.trim()) ? parseInt(arg, 10) : NaN; - if (!(n >= 0 && n <= 150)) return botSay(`❌ Usage: \`${P}volume [0-150]\``); - B.C.volume(n); - await later(250); - react(msg); - }, - async filter(msg, arg) { - const presets = "off, bassboost, nightcore, vaporwave, 8d, karaoke, soft, eq"; - const words = (arg || "").trim().toLowerCase().split(/\s+/).filter(Boolean); - if (!words.length) { - return botSay(`🎛️ Filter: **${B.filterLabel()}**\n\`${P}filter \` changes it: ${presets}. \`${P}filter eq \` sets the equaliser (-12 to 12 dB).`); - } - const [name, ...bands] = words; - const nums = bands.map((b) => (/^-?\d+$/.test(b) ? parseInt(b, 10) : NaN)); - const eq = {}; - ["low", "mid", "high"].forEach((k, i) => { if (i < nums.length) eq[k] = nums[i]; }); - if ((bands.length && name !== "eq") || nums.some((n) => !Number.isInteger(n)) || bands.length > 3) { - return botSay(`❌ Usage: \`${P}filter [${presets.replace(/, /g, "|")}]\`, or \`${P}filter eq \` (-12 to 12 dB)`); - } - await runControl(msg, () => B.C.filter(name, bands.length ? eq : null)); - }, - async autoplay(msg, arg) { - const mode = (arg || "").toLowerCase(); - if (!mode) { - return botSay(`📻 Autoplay is ${B.autoplay ? "on" : "off"}. Usage: \`${P}autoplay on|off\``); - } - if (mode !== "on" && mode !== "off") return botSay(`📻 Autoplay is ${B.autoplay ? "on" : "off"}. Usage: \`${P}autoplay on|off\``); - B.C.autoplay(mode === "on"); - await later(250); - react(msg); - }, - async stay(msg, arg) { - const mode = (arg || "").toLowerCase(); - if (mode !== "on" && mode !== "off") { - return botSay(`🌙 24/7 mode is ${B.stay ? "on" : "off"}. Usage: \`${P}stay on|off\``); - } - B.C.stay(mode === "on"); - botSay(mode === "on" - ? "🌙 24/7 mode on: I will stay in the voice channel, even when idle or after a restart." - : "🌙 24/7 mode off: I leave voice when the queue stays empty."); - await later(250); - react(msg); - }, - async stop(msg) { - if (!B.sid) return botSay("❌ No music is currently playing."); - B.C.stop(); - await later(250); - react(msg); - }, - async reset() { - await botSay("🔧 Resetting voice connection..."); - if (B.sid) { B.C.stop(); B.setAction(null); } - await later(1500); - await botSay("✅ Voice connection reset!"); - }, - async list() { - const q = B.inVoice ? B.queue : []; - if (!q.length) return botSay("❌ The queue is empty."); - let m = "🎶 **Queue:**\n" + q.slice(0, 10).map((t, i) => `${i + 1}. ${t.title}`).join("\n"); - if (q.length > 10) m += `\n... and ${q.length - 10} more tracks`; - return botSay(m); - }, - async help() { - await botSay(HELP); - tip(`Every command is also a slash command: \`/play\`, \`/next\`, \`/volume\`… This demo has no command picker, so type them in full.`); - }, - async theme(msg, arg) { - const choice = (arg || "").trim().toLowerCase(); - if (!choice) { - return botSay(`🎨 Clip page theme: **${B.theme.skin}** (server default)\nPresets: ${B.skins.join(", ")}\n` + - `\`${P}theme \` sets it, \`${P}theme reset\` restores \`arcade\`, ` + - `\`${P}theme edit\` sends you an editor link (custom colors, name, logo).`); - } - if (choice === "edit") { - await later(250); react(msg); - tip("The real bot sends the editor link by DM. Here it opens in the clip page panel."); - B.openSession(); - openClip("?edit=demo"); - return; - } - const skin = choice === "reset" ? "arcade" : choice; - if (!B.skins.includes(skin)) return botSay(`❌ Unknown theme. Presets: ${B.skins.join(", ")}, or \`reset\` / \`edit\`.`); - B.setSkin(skin); - await later(250); react(msg); - if (B.sid) B.setAction(`🎨 Clip page theme: ${skin}`); - }, - }; - - let busy = Promise.resolve(); - async function handle(text) { - const msg = post("you", text); - if (tour.step === "try") tourDone("cmd"); - // Every command is hybrid: \`/play\` works like \`!play\`. - const lead = text.startsWith(P) ? P : text.startsWith("/") ? "/" : null; - if (!lead) { - await later(500); - tip(`DJ Snake only reads commands that start with \`${P}\` (or slash commands like \`/play\`). Try \`${P}help\`, or \`${P}play \`.`); - return; - } - const [name, ...rest] = text.slice(lead.length).trim().split(/\s+/); - const cmd = COMMANDS[{ vs: "voteskip" }[(name || "").toLowerCase()] || (name || "").toLowerCase()]; - if (!cmd) { - await later(500); - tip(`\`${lead}${name || ""}\` is not a command in this demo, so nothing happens. Try \`${P}help\`, \`${P}next\`, \`${P}pause\`, \`${P}list\` or \`${P}play \`.`); - return; - } - await cmd(msg, rest.join(" ")); - } - $("composer").addEventListener("submit", (e) => { - e.preventDefault(); - const input = $("input"); - const text = input.value.trim(); - if (!text) return; - input.value = ""; - busy = busy.then(() => handle(text)).catch((err) => console.error(err)).then(() => { - // While the tour waits for the first !play, offer it again. - if (tour.step === "send" && !input.value) input.value = PLAY_CMD; - if (!B.inVoice && B.sid && !input.value && tour.step === "free") input.placeholder = `Message #bot-commands (try ${P}play)`; - }); - }); - - // ---- guided tour - const tour = { step: "send", bubbles: {} }; - function bubble(key, html, target, side, label) { - hideBubble(key); - const b = document.createElement("div"); - b.className = "bubble"; - b.setAttribute("role", "note"); - b.innerHTML = (label ? '' + label + "" : "") + html + - ''; - b.querySelector(".x").addEventListener("click", () => { hideBubble(key); if (!Object.keys(tour.bubbles).length && tour.step === "try") endTour(); }); - document.body.appendChild(b); - tour.bubbles[key] = { el: b, target, side }; - tourPlace(); - } - function hideBubble(key) { - const b = tour.bubbles[key]; - if (b) { b.el.remove(); delete tour.bubbles[key]; } - } - function tourPlace() { - const vw = window.innerWidth, vh = window.innerHeight; - for (const key in tour.bubbles) { - const { el, target, side } = tour.bubbles[key]; - const t = target(); - const r = t && t.getBoundingClientRect(); - if (!r || (!r.width && !r.height) || !t.offsetParent || r.bottom < 0 || r.top > vh) { el.style.visibility = "hidden"; continue; } - el.style.visibility = ""; - const w = el.offsetWidth, h = el.offsetHeight; - let left, top; - el.classList.remove("up", "down", "flat"); - if (side === "inside") { - left = r.left + (r.width - w) / 2; - top = r.top + 14; - el.classList.add("flat"); - } else { - left = Math.min(r.left + 8, r.right - w); - top = side === "top" ? r.top - h - 12 : r.bottom + 12; - el.classList.add(side === "top" ? "up" : "down"); - } - left = Math.max(12, Math.min(left, vw - w - 12)); - top = Math.max(56, Math.min(top, vh - h - 8)); - el.style.left = left + "px"; - el.style.top = top + "px"; - el.style.setProperty("--ax", Math.max(12, Math.min(w - 24, r.left + Math.min(28, r.width / 2) - left - 6)) + "px"); - } - } - window.addEventListener("resize", tourPlace); - msgs.addEventListener("scroll", tourPlace, { passive: true }); - setInterval(tourPlace, 300); - if (MOBILE.addEventListener) MOBILE.addEventListener("change", tourPlace); - - function endTour() { - tour.step = "free"; - Object.keys(tour.bubbles).forEach(hideBubble); - $("skiptour").hidden = true; - $("input").placeholder = "Message #bot-commands"; - } - function tourDone(what) { - if (what === "send" && tour.step === "send") { hideBubble("send"); tour.step = "link"; } - else if (what === "web" || what === "cmd") { - hideBubble(what); - if (tour.step === "try" && !tour.bubbles.web && !tour.bubbles.cmd) endTour(); - } - } - function tourPanelPosted() { - if (tour.step !== "link") return; - bubble("link", "Click the clip link to open the bot's web page" + (MOBILE.matches ? "." : " on the right."), - () => panelEl && panelEl.querySelector("a[data-clip]"), "top", "Step 2 of 3"); - } - function tourFrameLoaded() { - if (tour.step !== "link") return; - hideBubble("link"); - tour.step = "try"; - bubble("web", "This is the clip page. Skip ⏭, click the red bar to seek, drag a track or click one to play it now. The Discord panel follows.", - () => frame, "inside", "Step 3 of 3"); - bubble("cmd", "Or type a command here: !pause, !next, !list, !help", - () => $("composer"), "top", MOBILE.matches ? "Step 3 of 3" : null); - $("input").placeholder = `Try ${P}pause, ${P}next, ${P}list or ${P}help`; - } - $("skiptour").addEventListener("click", endTour); - - // ---- start - renderVoice(); - post("maya", "can someone put on the friday playlist? 🎶", { time: "Today at " + new Date(Date.now() - 4 * 60000).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) }); - post("theo", "you have the Spotify link, go ahead 👇", { time: "Today at " + new Date(Date.now() - 3 * 60000).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) }); - lastAuthor = null; - $("input").value = PLAY_CMD; - bubble("send", (MOBILE.matches ? "Tap ➤ to send" : "Press Enter to send") + " this command. DJ Snake plays Spotify and YouTube links.", - () => $("composer"), "top", "Step 1 of 3"); - if (!MOBILE.matches) $("input").focus({ preventScroll: true }); -})(); diff --git a/docs/demo/index.html b/docs/demo/index.html deleted file mode 100644 index 3260099..0000000 --- a/docs/demo/index.html +++ /dev/null @@ -1,282 +0,0 @@ - - - - - -DJ Snake live demo - - - - - -
-
- 🐍 DJ Snake Live demo - A Discord music bot with a synced clip page. Everything here is simulated in your browser. - -
- -
-
- - -
-
- #bot-commands - Music requests go here. DJ Snake reads commands that start with ! and slash commands like /play -
🔊 Lounge  
- -
-
-
-
#
-

Welcome to #bot-commands!

-

This is a simulated Discord channel. Send commands to DJ Snake and watch the clip page follow. Footage: excerpts of CC BY films by the Blender Foundation (credits).

-
-
- -
- - -
-
-
- -
-
- - -
🔒dj.example.com
-
-
-
-
-
📺
-

The clip page opens here

-

When DJ Snake joins voice, it posts a status panel with a clip link. Click it to watch the video in sync with the music and control the queue from the browser.

- -
-
-
-
-
-
- - - - diff --git a/docs/demo/make-media.sh b/docs/demo/make-media.sh deleted file mode 100755 index 7ea428d..0000000 --- a/docs/demo/make-media.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# Rebuilds the demo footage in media/ from the original films (CC-BY, see the -# credits in docs/README.md). Output: clipN.webm (640x360 VP9, no audio, ~25 s, -# metadata stripped) and thumbN.jpg (real YouTube thumbnail, 320x180). -# -# Usage: make-media.sh [SRC_DIR] -# SRC_DIR holds rv_.mp4 (video, <= 720p) and rv_.jpg (YouTube thumbnail). -# Fetch them with yt-dlp, e.g. per id: -# yt-dlp -f "bv*[height<=720]" --write-thumbnail --convert-thumbnails jpg \ -# -o "SRC_DIR/rv_.%(ext)s" https://www.youtube.com/watch?v= -# Uses the local ffmpeg, else the project's Docker image (dj-snake:latest). -set -euo pipefail -SRC=$(cd "${1:-.}" && pwd) -OUT="$(cd "$(dirname "$0")" && pwd)/media" -mkdir -p "$OUT" -if command -v ffmpeg >/dev/null; then - FF=(ffmpeg) -else - FF=(docker run --rm -v "$SRC:/src:ro" -v "$OUT:/out" --entrypoint ffmpeg dj-snake:latest) - SRC=/src; OUT=/out -fi -# n|youtube id|start (s)|length (s)|thumbnail has baked-in letterbox bars (1/0) -TRACKS=( - "1|SkVqJ1SGeL0|76|25|0" # Caminandes 3: Llamigos - "2|Y-rmzh0PI3c|528|25|0" # Cosmos Laundromat - "3|aqz-KE-bpKQ|188|25|0" # Big Buck Bunny - "4|R6MlUcmOul8|345|25|1" # Tears of Steel - "5|eRsGyueVLvQ|306|25|0" # Sintel -) -for t in "${TRACKS[@]}"; do - IFS='|' read -r n id ss len bars <<<"$t" - TC=""; [ "$bars" = 1 ] && TC="crop=iw:ih*0.76:0:ih*0.12," - # crop to 16:9 (scope films have black bars baked in), then scale - VF="crop='min(iw,ih*16/9)':'min(ih,iw*9/16)',scale=640:360,fps=24,format=yuv420p" - "${FF[@]}" -y -v error -ss "$ss" -t "$len" -i "$SRC/rv_$id.mp4" -vf "$VF" \ - -c:v libvpx-vp9 -b:v 0 -crf 41 -row-mt 1 -g 96 -an \ - -map_metadata -1 -fflags +bitexact -flags:v +bitexact "$OUT/clip$n.webm" - "${FF[@]}" -y -v error -i "$SRC/rv_$id.jpg" \ - -vf "${TC}crop='min(iw,ih*16/9)':'min(ih,iw*9/16)',scale=320:180" -q:v 5 \ - -map_metadata -1 "$OUT/thumb$n.jpg" -done -ls -l "$(cd "$(dirname "$0")" && pwd)/media" diff --git a/docs/demo/media/clip1.webm b/docs/demo/media/clip1.webm deleted file mode 100644 index c69b96f..0000000 Binary files a/docs/demo/media/clip1.webm and /dev/null differ diff --git a/docs/demo/media/clip2.webm b/docs/demo/media/clip2.webm deleted file mode 100644 index 8a09927..0000000 Binary files a/docs/demo/media/clip2.webm and /dev/null differ diff --git a/docs/demo/media/clip3.webm b/docs/demo/media/clip3.webm deleted file mode 100644 index 3b05b0f..0000000 Binary files a/docs/demo/media/clip3.webm and /dev/null differ diff --git a/docs/demo/media/clip4.webm b/docs/demo/media/clip4.webm deleted file mode 100644 index 3a80b4e..0000000 Binary files a/docs/demo/media/clip4.webm and /dev/null differ diff --git a/docs/demo/media/clip5.webm b/docs/demo/media/clip5.webm deleted file mode 100644 index 306fd95..0000000 Binary files a/docs/demo/media/clip5.webm and /dev/null differ diff --git a/docs/demo/media/thumb1.jpg b/docs/demo/media/thumb1.jpg deleted file mode 100644 index 6332a1a..0000000 Binary files a/docs/demo/media/thumb1.jpg and /dev/null differ diff --git a/docs/demo/media/thumb2.jpg b/docs/demo/media/thumb2.jpg deleted file mode 100644 index 3dc50a3..0000000 Binary files a/docs/demo/media/thumb2.jpg and /dev/null differ diff --git a/docs/demo/media/thumb3.jpg b/docs/demo/media/thumb3.jpg deleted file mode 100644 index fa210d8..0000000 Binary files a/docs/demo/media/thumb3.jpg and /dev/null differ diff --git a/docs/demo/media/thumb4.jpg b/docs/demo/media/thumb4.jpg deleted file mode 100644 index 6ff927e..0000000 Binary files a/docs/demo/media/thumb4.jpg and /dev/null differ diff --git a/docs/demo/media/thumb5.jpg b/docs/demo/media/thumb5.jpg deleted file mode 100644 index 0472a18..0000000 Binary files a/docs/demo/media/thumb5.jpg and /dev/null differ diff --git a/docs/demo/player.html b/docs/demo/player.html deleted file mode 100644 index 2cf6bc8..0000000 --- a/docs/demo/player.html +++ /dev/null @@ -1,4480 +0,0 @@ - - - - - - - - -DJ Snake — Clip Party - - - - - - - - - - - - - - - - - -
-
- -
-
- - - - - -
- -
Connecting…
- -
-
-
-
-

 

- -
-
-
- -
-
- - - - - -
-
- - - - -
-
- - -
-
-
- - -
- - - - -
-
-

Keyboard shortcuts

- -
-

- - - -
KeysAction
-
-
- - -
-

Long playlist

-

- -
- - - -
-
-
- - - - - - - -
- Demo: CC BY film excerpts (Blender) - Discord demo - Theme editor - View-only link - Credits -
- - diff --git a/docs/demo/shim.js b/docs/demo/shim.js deleted file mode 100644 index 64456e4..0000000 --- a/docs/demo/shim.js +++ /dev/null @@ -1,104 +0,0 @@ -/* DJ Snake demo shim. Replaces window.fetch for the bot's web API so the real - * clip page (web/index.html, built into player.html) runs on static hosting. - * Intercepted: GET /api/{sid}/state, GET /api/{sid}/stats, POST /api/{sid}/control, - * POST /api/{sid}/theme, POST|DELETE /api/{sid}/theme/logo, - * GET /api/{sid}/lyrics/{id}, and the live update stream - * GET /api/{sid}/events (a fake EventSource). - * Embedded in the Discord demo (index.html), it talks to the wrapper's fake - * bot (window.parent.DJDemoBackend.shared), so both sides share one session; - * opened on its own, it runs a standalone fake bot that is always playing and - * now and then loads a fake playlist (the "Downloading" list). */ -(() => { - "use strict"; - - let backend = null, embedded = false; - try { - if (window.parent !== window && window.parent.DJDemoBackend && window.parent.DJDemoBackend.shared) { - backend = window.parent.DJDemoBackend.shared; - embedded = true; - } - } catch (e) {} - if (!backend) backend = window.DJDemoBackend.create({ standalone: true }); - if (embedded) document.documentElement.classList.add("demo-embedded"); - - // `?view` plays the view-only link: the fake API answers as web.py does - // for a view-only id (no controls, 403 on control). - let view = false; - // `?edit=demo` opens the theme editor: give the page a well-formed token. - try { - const p = new URLSearchParams(location.search); - view = p.has("view"); - if (p.get("edit") === "demo") { - p.set("edit", "demo-edit-token-0000"); - history.replaceState(null, "", location.pathname + "?" + p.toString()); - } - } catch (e) {} - - // Standalone, nobody adds anything: load a fake playlist now and then so - // the "Downloading" list shows how downloads queue up. - if (!embedded && backend.demoLoad) { - setTimeout(() => backend.demoLoad(5), 1500); - setInterval(() => backend.demoLoad(4), 45000); - } - - // A real YouTube link as the example in the add field. - document.addEventListener("DOMContentLoaded", () => { - const f = document.getElementById("addurl"); - if (f) f.placeholder = "https://www.youtube.com/watch?v=" + backend.catalog[0][1]; - }); - - // Live updates (GET /api/{sid}/events): a fake EventSource fed by the fake - // bot's change events, coalesced like the server's stream, plus a state - // every 5 s standing in for the server's state diff (viewers come and go). - class FakeEventSource { - constructor(url) { - this.url = url; - this.readyState = 0; - this.onopen = this.onmessage = this.onerror = null; - this._timer = null; - this._off = backend.on((ev) => { - if (ev.type === "change" || ev.type === "voice") this._queue(); - }); - this._tick = setInterval(() => this._queue(), 5000); - this._timer = setTimeout(() => this._send(), 40); - } - addEventListener() {} // `ping`: the 5 s states keep the page's liveness check happy - _queue() { if (!this._timer) this._timer = setTimeout(() => this._send(), 100); } - _send() { - this._timer = null; - if (this.readyState === 2) return; - this.readyState = 1; - // JSON round trip: the answer comes from the parent window's realm. - const res = backend.http("state", "GET", null, view); - const data = JSON.stringify(res.status === 200 ? res.body : { active: false }); - if (this.onmessage) this.onmessage({ data }); - if (res.status !== 200) this.close(); // the server ends the stream too - } - close() { - this.readyState = 2; - this._off(); - clearInterval(this._tick); - clearTimeout(this._timer); - } - } - window.EventSource = FakeEventSource; - - const realFetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const url = typeof input === "string" ? input : input.url; - const m = /\/api\/[^/]+\/(state|stats|control|playlists|theme(?:\/logo)?|lyrics\/[^/?]+)(?:\?|$)/.exec(url); - if (!m) return realFetch(input, init); - const method = ((init && init.method) || "GET").toUpperCase(); - let body = null; - try { body = init && typeof init.body === "string" ? JSON.parse(init.body) : null; } catch (e) {} - if (m[1] === "stats") { - try { body = { range: new URL(url, location.href).searchParams.get("range") || "week" }; } catch (e) {} - } - await new Promise((r) => setTimeout(r, 40)); - // JSON round trip: the answer comes from the parent window's realm. - const res = backend.http(m[1], method, body, view); - return new Response(JSON.stringify(res.body), { - status: res.status, headers: { "Content-Type": "application/json" }, - }); - }; -})(); diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 232a4c4..0000000 --- a/docs/index.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - -DJ Snake — a self-hosted Discord music bot - - - - - - - - - - - - - -
-
- - -
- -
-
-
-

DJ
Snake

-

A self-hosted Discord music bot. It plays music from YouTube, Spotify, SoundCloud, Bandcamp, Deezer and Apple Music links, or a plain text search, in your voice channel, and serves a live clip page where the video stays in sync with the audio.

- -
-
- -
-

What you get

-
    -
  • Links from six services, or just a search

    Send a YouTube, SoundCloud or Bandcamp link, or a Spotify, Deezer or Apple Music playlist, album or track: DJ Snake reads the track list and finds each song on YouTube (the official video first, no live, cover or remix versions), skipping what it cannot find. Or type a few words and it picks the best match. No account needed, nothing is downloaded from the streaming services.

  • -
  • Slash commands and a calm channel

    Every command also works as /command. One status message is edited in place instead of a channel full of announcements, and commands answer with a reaction.

  • -
  • Live clip page

    Every server gets a link with the muted video of the current track, synced with the voice audio, plus synced lyrics (karaoke style). Drag to reorder, click a track to play it now, seek anywhere, add or remove tracks. Updates are pushed live.

  • -
  • Playback your way

    Loop a track or the queue, shuffle, set the volume, or turn on autoplay radio to keep the music going when the queue ends. Save playlists per server and load them later. One independent queue per server.

  • -
  • Automix

    Off-topic segments cut with SponsorBlock, loudness normalised to -14 LUFS, silences trimmed, smooth fades. Optional crossfade between tracks.

  • -
  • Who can control it

    Share the view-only link freely and keep the control link for DJs. Set a DJ role to restrict skipping, stopping and settings, in Discord and on the page.

  • -
  • English, French, Spanish and German

    Bot replies, help and the clip page speak English, French, Spanish or German, set per server. The page can also be themed, if you like.

  • -
  • Self-hosted, low upkeep

    One docker compose up starts the bot and its PO token sidecar. yt-dlp upgrades itself on start, so it keeps working when YouTube changes, and a /healthz endpoint backs the container healthcheck.

  • -
-
- -
-

Self-host it in 3 commands

-
# create a Discord bot, enable the Message Content intent, then:
-$ git clone https://github.com/selfhostr/djsnake.git && cd djsnake
-$ cp .env.example .env   # set DISCORD_TOKEN
-$ docker compose pull && docker compose up -d
-

Then join a voice channel and send /play or !play with a link or a search. Full setup, configuration and troubleshooting are in the README.

-
-
- - -
- - diff --git a/docs/social-preview.png b/docs/social-preview.png deleted file mode 100644 index e5ccff4..0000000 Binary files a/docs/social-preview.png and /dev/null differ diff --git a/docs/source/architecture.md b/docs/source/architecture.md index 8d2dcc0..a6adf98 100644 --- a/docs/source/architecture.md +++ b/docs/source/architecture.md @@ -91,10 +91,6 @@ The bot is a `commands.AutoShardedBot`: discord.py asks Discord for the recommen Everything persistent lives in `DATA_DIR`: `themes.json` (per-server themes, written atomically), `languages.json` (per-server languages), `volume.json` (per-server volume), `voteskip.json` (vote-skip thresholds), `permissions.json` (DJ roles), `limits.json` (queue limits), `stay.json` (24/7 mode and its voice channel), `lastfm.json` (Last.fm session keys, mode 0600), `playlists.db` (saved playlists), `logos/`, `stats.db` (SQLite, WAL) and `lyrics/` (lyrics found on LRCLIB, one JSON file per YouTube video id). `backup.py` archives all of it; see [Backup and restore](backup.md). -## Demo site - -`docs/index.html` and `docs/demo/` are a GitHub Pages site with a simulated Discord client and the real clip page running on a fake session. The demo player is generated from `web/index.html` by `docs/demo/build.py`. - ## Conventions - User-facing messages, code comments and docs are in English. diff --git a/docs/source/contributing.md b/docs/source/contributing.md index 7341210..5cb553f 100644 --- a/docs/source/contributing.md +++ b/docs/source/contributing.md @@ -115,5 +115,3 @@ Never commit `.env`, a `token` file or any real token, hostname or IP. ## Releasing Move the `[Unreleased]` notes in `CHANGELOG.md` under a new version, commit, then tag `vX.Y.Z` and push the tag. The release workflow builds and pushes the multi-arch `ghcr.io/selfhostr/djsnake` image and creates the GitHub Release. - -After changing `web/index.html` (or `docs/demo/shim.js`), run `python3 docs/demo/build.py` and commit the regenerated `docs/demo/player.html`. diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index 7318bb0..0d45565 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -64,7 +64,7 @@ def test_layout_independent_matching(): def test_docs_list_shortcuts(): - for doc in (ROOT / "README.md", ROOT / "docs" / "source" / "clip-page.md"): + for doc in (ROOT / "docs" / "source" / "clip-page.md",): text = doc.read_text(encoding="utf-8") assert "Keyboard shortcuts" in text, doc diff --git a/tests/test_media_session.py b/tests/test_media_session.py index d20db1b..7490dd3 100644 --- a/tests/test_media_session.py +++ b/tests/test_media_session.py @@ -1,19 +1,11 @@ -"""Clip page Media Session wiring (string-level checks on web/index.html, plus a -headless Chromium run of the static demo when playwright is installed).""" +"""Clip page Media Session wiring (string-level checks on web/index.html).""" -import functools -import http.server -import os import re -import threading import pytest from djsnake import config -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -DEMO = os.path.join(ROOT, "docs", "demo") - @pytest.fixture(scope="module") def page(): @@ -63,45 +55,3 @@ def test_silent_audio_starts_on_user_gesture(page): assert '"pointerdown", "keydown", "touchend"' in page assert "msAudio.loop = true" in page assert 'type: "audio/wav"' in page - - -@pytest.fixture -def demo_server(): - handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=DEMO) - handler.log_message = lambda *a, **k: None - srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) - threading.Thread(target=srv.serve_forever, daemon=True).start() - yield srv.server_address[1] - srv.shutdown() - - -def test_demo_sets_media_session_metadata(demo_server): - sync = pytest.importorskip("playwright.sync_api") - with sync.sync_playwright() as p: - try: - browser = p.chromium.launch() - except Exception as e: # browser binary not installed - pytest.skip(str(e)) - pg = browser.new_page() - pg.add_init_script( - "window.__h = {}; const o = MediaSession.prototype.setActionHandler;" - "MediaSession.prototype.setActionHandler = function (a, f) {" - " window.__h[a] = !!f; return o.call(this, a, f); };" - ) - pg.goto("http://127.0.0.1:%d/player.html" % demo_server) - pg.wait_for_function( - "navigator.mediaSession.metadata && navigator.mediaSession.metadata.title", - timeout=15000, - ) - meta = pg.evaluate( - "({t: navigator.mediaSession.metadata.title," - " al: navigator.mediaSession.metadata.album," - " art: navigator.mediaSession.metadata.artwork.length," - " st: navigator.mediaSession.playbackState, h: window.__h})" - ) - browser.close() - assert meta["t"] - assert meta["art"] >= 1 - assert meta["st"] in ("playing", "paused") - assert meta["h"].get("nexttrack") is True - assert meta["h"].get("seekto") is True diff --git a/tests/test_web_layout.py b/tests/test_web_layout.py deleted file mode 100644 index 859c840..0000000 --- a/tests/test_web_layout.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Headless Chromium layout checks for the clip page (skipped without playwright).""" - -import functools -import http.server -import pathlib -import threading - -import pytest - -sync_api = pytest.importorskip("playwright.sync_api") - -DEMO = pathlib.Path(__file__).resolve().parent.parent / "docs" / "demo" -SKINS = ["arcade", "minimal", "night", "vinyl", "club", "terminal", "space", "cobalt"] - - -class _Quiet(http.server.SimpleHTTPRequestHandler): - def log_message(self, *args): - pass - - -@pytest.fixture(scope="module") -def browser(): - handler = functools.partial(_Quiet, directory=str(DEMO)) - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) - threading.Thread(target=server.serve_forever, daemon=True).start() - with sync_api.sync_playwright() as pw: - try: - br = pw.chromium.launch() - except Exception as exc: # browser binary not installed - server.shutdown() - pytest.skip(f"chromium unavailable: {exc}") - yield br, f"http://127.0.0.1:{server.server_address[1]}/player.html" - br.close() - server.shutdown() - - -JS = """() => { - const deck = document.querySelector('.transport').parentElement.getBoundingClientRect(); - return { - deck: [deck.left, deck.right], - scroll: document.documentElement.scrollWidth, - buttons: [...document.querySelectorAll('.tcenter button')].map(b => { - const r = b.getBoundingClientRect(); - return {id: b.id, left: r.left, right: r.right, width: r.width, height: r.height}; - }), - }; -}""" - - -@pytest.mark.parametrize("width", [320, 360]) -@pytest.mark.parametrize("skin", SKINS) -def test_transport_buttons_stay_inside_player(browser, skin, width): - br, url = browser - ctx = br.new_context(viewport={"width": width, "height": 700}) - ctx.add_init_script( - f"try{{localStorage.setItem('djsnake.skin','{skin}')}}catch(e){{}}" - ) - page = ctx.new_page() - try: - page.goto(url) - page.wait_for_selector(".tcenter #loop") - page.wait_for_timeout(500) - got = page.evaluate(JS) - finally: - ctx.close() - ids = [b["id"] for b in got["buttons"]] - assert ids == ["shuffle", "prev", "pp", "next", "loop"] - for b in got["buttons"]: - assert b["left"] >= got["deck"][0] - 0.5, (skin, b) - assert b["right"] <= got["deck"][1] + 0.5, (skin, b) - assert b["width"] >= 40 and b["height"] >= 40, (skin, b) - assert got["scroll"] <= width, skin