/* @licstart The following is the entire license notice for the JavaScript code in this file. Relibre Copyleft (🄯) 2025 James Osborne SPDX-License-Identifier: AGPL-3.0-or-later This file is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This file is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . @licend The above is the entire license notice for the JavaScript code in this file. */ 'use strict'; // Single source of truth for icons. Anything provided by icons.js (window.RELIBRE_ICON) // will override these defaults. Leave defaults empty so missing icons simply render // without an . const ICONS = Object.assign({ website: '', bluesky: '', mastodon: '', pixelfed: '', peertube: '', lemmy: '', owncast: '', applemusic: '', spotify: '', bandcamp: '', youtube: '', reddit: '', facebook: '', instagram: '', tiktok: '', threads: '', friendica: '', 'diaspora*': '', x: '', blog: '', email: '', soundcloud: '', tidal: '' }, (window.RELIBRE_ICON || {})); (function () { const STYLE_CSS = ` :root{color-scheme:dark;--bg:#0b0b0c;--panel:#121214;--fg:#eaeaea;--muted:#9aa0a6;--acc:#e53935;--acc2:#ff7aa5;--br:18px;--input:#1a1b1e;--input-br:#2a2c30} *{box-sizing:border-box} html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif} a{color:#9bd1ff} .hf-container{max-width:860px;margin:40px auto;padding:0 18px} .hf-card{background:var(--panel);border:1px solid #1d1f21;border-radius:var(--br);padding:22px;margin:16px 0;box-shadow:0 12px 38px rgba(0,0,0,.45)} .hf-hero{display:block} .hf-cover{display:block;width:100%;height:auto;border-radius:14px;margin:4px 0 16px} .hf-title{font-size:clamp(28px,4.6vw,42px);line-height:1.1;margin:0 0 4px;font-weight:800} .hf-subtitle{color:var(--muted);margin:0 0 14px} .hf-cta-row{display:flex;gap:12px;flex-wrap:wrap;margin:12px 0} .btn{display:inline-flex;gap:8px;align-items:center;padding:12px 16px;border-radius:999px;text-decoration:none;font-weight:700} .btn-ghost{background:#1b1c1f;border:1px solid #2a2c30;color:var(--fg)} .btn-red{background:var(--acc);color:#fff;border:0} .hf-embed{margin-top:16px} .hf-desc{margin-top:10px;white-space:pre-wrap} .hf-section-title{font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:#ddd;margin:0 0 10px;font-weight:800} .hf-link-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:12px} .hf-link{display:flex;gap:10px;align-items:center;padding:14px 16px;border-radius:12px;background:#191a1d;border:1px solid #2a2c30;text-decoration:none;color:var(--fg)} .hf-link img{display:block;width:24px;height:24px} .hf-video, video{width:100%;border-radius:12px} .hf-title, .hf-subtitle { text-align: center; } .hf-cta-row { justify-content: center; } .hf-footer{margin:12px 0 28px;text-align:center;color:var(--muted);font-size:13px} .hf-footer a{text-decoration:none} .hf-footer a:hover{filter:brightness(1.1)} /* ---- Embeds ---- */ /* Clip corners on embeds and give consistent radius */ .hf-embed{overflow:hidden;border-radius:12px} /* Bandcamp: center and match inner fixed width to avoid right-gutter */ .bc-wrap{display:flex;justify-content:center} .bc-iframe{display:block;border:0;width:700px;max-width:100%} /* YouTube: responsive 16:9 */ .hf-video{aspect-ratio:16/9} .hf-video iframe{width:100%;height:100%;display:block;border:0} /* Consistent tile hover for all services (no brand tints) */ .hf-link{ transition: background .2s ease, border-color .2s ease, color .2s ease, transform .06s ease-out, box-shadow .2s ease; } .hf-link:hover{ background:#24262a; border-color:#3a3c40; color:#fff; transform:translateY(-1px); box-shadow:0 0 0 3px rgba(255,255,255,.02), inset 0 0 0 1px rgba(255,255,255,.04); } .hf-link:hover span{ color:inherit } .hf-link:focus-visible{ outline:2px solid var(--acc2); outline-offset:3px } .hf-link:active{ transform:translateY(0) } @media (max-width:720px){ .hf-link-grid{grid-template-columns:1fr} } `; const SCRIPT_JS = ''; const CREDIT_FOOTER = ``; const TEMPLATE_HTML = ` {{TITLE}} — {{ARTIST}}
{{TITLE}} cover art

{{TITLE}}

{{ARTIST}}

{{BUY_BUTTON_HTML}} {{STREAM_MAIN_BUTTON_HTML}}
{{AUDIO_EMBED_HTML}} {{DESCRIPTION_HTML}}
{{WATCH_HTML}} {{SUPPORT_SECTION_HTML}} {{STREAM_SECTION_HTML}} {{FOLLOW_SECTION_HTML}} {{CREDIT_FOOTER}}
`; const $ = (sel, root=document) => root.querySelector(sel); const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel)); const FOLLOW_SERVICES = [ 'blog','website','mastodon','pixelfed','bluesky','peertube','owncast','lemmy','friendica','diaspora*','email' ]; function setEnvStatus(){ const proto = location.protocol.replace(':',''); const el = $('#envStatus'); if (el) el.textContent = `Protocol: ${proto}. Relibre generator works directly from file:// — no server required.`; } function createServiceRow(serviceKey, container){ const row = document.createElement('div'); row.className = 'grid2'; row.innerHTML = `
`; container.appendChild(row); } // Add a custom row with different presets per section and a free-text label for [Custom] function addCustomRow(container, type){ const opts = (type === 'stream') ? ['[Custom]', 'SoundCloud', 'YouTube', 'Tidal'] : ['[Custom]', 'X', 'Instagram', 'Facebook', 'TikTok', 'Twitch', 'Threads', 'YouTube', 'Reddit']; const optionsHtml = opts.map(v => ``).join(''); const row = document.createElement('div'); row.className = 'grid2'; row.innerHTML = ` `; const sel = row.querySelector('[data-svc="custom-label"]'); const txt = row.querySelector('[data-svc="custom-label-text"]'); const update = () => txt.classList.toggle('hidden', sel.value !== '[Custom]'); sel.addEventListener('change', update); update(); container.appendChild(row); } function toggleRadios(name,map){ const inputs = $$(`input[name="${name}"]`); const update = () => { const val = inputs.find(i=>i.checked)?.value; Object.entries(map).forEach(([key,el]) => el.classList.toggle('hidden', key !== val)); }; inputs.forEach(r=>r.addEventListener('change', update)); update(); } function mdToHtml(md){ if(!md) return ''; let html = md.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])) .replace(/\*\*([^*]+)\*\*/g,'$1') .replace(/\*([^*]+)\*/g,'$1') .replace(/`([^`]+)`/g,'$1') .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,'$1'); const lines = html.split(/\r?\n/); let out = '', inOl=false, inUl=false; for(const line of lines){ if(/^\s*\d+\.\s+/.test(line)){ if(!inOl){out+='
    '; inOl=true;} out+='
  1. '+line.replace(/^\s*\d+\.\s+/,'')+'
  2. '; continue; } if(/^\s*[-*]\s+/.test(line)){ if(!inUl){out+='
'; inOl=false;} if(inUl){out+=''; inUl=false;} if(line.trim()) out+='

'+line+'

'; } if(inOl) out+=''; if(inUl) out+=''; return out; } function fileToDataURL(file){ return new Promise((resolve,reject)=>{ const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = reject; fr.readAsDataURL(file); }); } const LABELS = { applemusic: 'Apple Music', amazonmusic: 'Amazon Music', soundcloud: 'SoundCloud', tidal: 'Tidal', tiktok: 'TikTok', 'diaspora*': 'diaspora*', x: 'X' }; function pretty(k){ return LABELS[k] || k.charAt(0).toUpperCase() + k.slice(1); } function escapeHtml(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } // Map normalized key -> icon key function getServiceIcon(key){ const map = { spotify:'spotify', applemusic:'apple', bandcamp:'bandcamp', soundcloud:'soundcloud', tidal:'tidal', mastodon:'mastodon', pixelfed:'pixelfed', peertube:'peertube', lemmy:'lemmy', owncast:'owncast', bluesky:'bluesky', facebook:'facebook', instagram:'instagram', twitter:'x', threads:'threads', tiktok:'tiktok', email:'email', youtube:'youtube', reddit:'reddit', website:'website', blog:'blog', friendica:'friendica', 'diaspora*':'diaspora', x:'x' }; const iconKey = map[key] || 'website'; return ICONS[iconKey] || ''; } // Normalize a display label to a lookup key (e.g., "Apple Music" -> "applemusic") function labelToKey(label){ return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g,''); } function renderButtonsGrid(title, buttons, id){ if(!buttons.length) return ''; const items = buttons.map(b => { const key = labelToKey(b.label); const icon = getServiceIcon(key); const img = icon ? `` : ''; return `${img}${escapeHtml(b.label)}`; }).join(''); const idAttr = id ? ` id="${id}"` : ''; return `

${escapeHtml(title)}

`; } // ---- Embeds normalizers ---- // Bandcamp: force dark, clamp to inner fixed width, and center function normalizeBandcampEmbed(rawHtml){ if(!rawHtml) return ''; const wrap = document.createElement('div'); wrap.innerHTML = rawHtml.trim(); const iframe = wrap.querySelector('iframe'); if(!iframe) return rawHtml; let src = iframe.getAttribute('src') || ''; const setSeg = (name, val) => { const re = new RegExp(`${name}=[^/]+`); if (re.test(src)) src = src.replace(re, `${name}=${val}`); else src += (src.endsWith('/') ? '' : '/') + `${name}=${val}/`; }; setSeg('bgcol', '121214'); // matches --panel setSeg('linkcol', 'eaeaea'); // matches --fg setSeg('transparent', 'true'); iframe.setAttribute('src', src); const size = (src.match(/size=([^/]+)/)?.[1] || 'large').toLowerCase(); const innerW = size === 'small' ? 350 : 700; iframe.classList.add('bc-iframe'); iframe.removeAttribute('width'); iframe.removeAttribute('height'); iframe.style.width = innerW + 'px'; return `
${iframe.outerHTML}
`; } // YouTube: strip fixed w/h so CSS controls aspect-ratio function normalizeYouTubeEmbed(rawHtml){ if(!rawHtml) return ''; const wrap = document.createElement('div'); wrap.innerHTML = rawHtml.trim(); const iframe = wrap.querySelector('iframe'); if(!iframe) return rawHtml; iframe.removeAttribute('width'); iframe.removeAttribute('height'); return iframe.outerHTML; } // ---- App ---- window.addEventListener('DOMContentLoaded', () => { setEnvStatus(); const streamList = $('#streamList'); const followList = $('#followList'); ['spotify','applemusic','bandcamp'].forEach(s => createServiceRow(s, streamList)); $('#addCustomStream').addEventListener('click', () => addCustomRow(streamList, 'stream')); FOLLOW_SERVICES.forEach(s => createServiceRow(s, followList)); $('#addCustomFollow').addEventListener('click', () => addCustomRow(followList, 'follow')); toggleRadios('audioType', { bandcamp: $('#bandcampBox'), self: $('#selfAudioBox') }); toggleRadios('watchType', { youtube: $('#watchIframeBox'), self: $('#watchSelfBox') }); $('#generate').addEventListener('click', async () => { try{ $('#status').textContent=''; const artwork = $('#artwork').files[0]; const title = $('#title').value.trim(); const artist = $('#artist').value.trim(); const buyLabel = $('#buyLabel').value.trim() || 'Buy on Bandcamp'; const buyUrl = $('#buyUrl').value.trim(); const audioType = $('input[name="audioType"]:checked').value; const bandcampIframe = $('#bandcampIframe').value.trim(); const audioMp3Url = $('#audioMp3Url')?.value.trim() || ''; const audioOggUrl = $('#audioOggUrl')?.value.trim() || ''; const descHtml = mdToHtml($('#desc').value); const watchType = $('input[name="watchType"]:checked').value; const watchIframe = $('#watchIframe').value.trim(); const videoMp4Url = $('#videoMp4Url')?.value.trim() || ''; const videoWebmUrl = $('#videoWebmUrl')?.value.trim() || ''; const videoPosterUrl = $('#videoPosterUrl')?.value.trim() || ''; const supportBandcamp = $('#supportBandcamp').value.trim(); // Streams const streamButtons = []; streamList.querySelectorAll('input[type="url"]:not([data-svc="custom-url"])') .forEach(inp => { if (inp.value.trim()) streamButtons.push({ label: pretty(inp.dataset.svc), url: inp.value.trim() }); }); streamList.querySelectorAll('input[data-svc="custom-url"]').forEach(inp => { const row = inp.closest('.grid2'); const sel = row.querySelector('[data-svc="custom-label"]'); let lbl = sel.value.trim(); if (lbl === '[Custom]') lbl = row.querySelector('[data-svc="custom-label-text"]').value.trim(); if (inp.value.trim() && lbl) streamButtons.push({ label: lbl, url: inp.value.trim() }); }); // Follows const followButtons = []; followList.querySelectorAll('input[type="url"]:not([data-svc="custom-url"])') .forEach(inp => { if (inp.value.trim()) followButtons.push({ label: pretty(inp.dataset.svc), url: inp.value.trim() }); }); followList.querySelectorAll('input[data-svc="custom-url"]').forEach(inp => { const row = inp.closest('.grid2'); const sel = row.querySelector('[data-svc="custom-label"]'); let lbl = sel.value.trim(); if (lbl === '[Custom]') lbl = row.querySelector('[data-svc="custom-label-text"]').value.trim(); if (inp.value.trim() && lbl) followButtons.push({ label: lbl, url: inp.value.trim() }); }); if(!artwork || !title || !artist){ alert('Artwork, title, and artist are required.'); return; } if(!buyUrl && streamButtons.length===0){ if(!confirm('No Buy URL and no Stream buttons provided. Continue anyway?')) return; } const coverDataURL = await fileToDataURL(artwork); const BUY_BUTTON_HTML = buyUrl ? `${escapeHtml(buyLabel)}` : ''; const STREAM_MAIN_BUTTON_HTML = streamButtons.length ? `Stream` : ''; let AUDIO_EMBED_HTML = ''; if (audioType === 'bandcamp' && bandcampIframe) { const fixed = normalizeBandcampEmbed(bandcampIframe); AUDIO_EMBED_HTML = `
${fixed}
`; } else if (audioType === 'self' && (audioMp3Url || audioOggUrl)) { const sources = [ audioMp3Url ? `` : '', audioOggUrl ? `` : '' ].join(''); AUDIO_EMBED_HTML = `
`; } const DESCRIPTION_HTML = descHtml ? `
${descHtml}
` : ''; let WATCH_HTML = ''; if (watchType === 'youtube' && watchIframe){ const yt = normalizeYouTubeEmbed(watchIframe); WATCH_HTML = `
${yt}
`; } else if (watchType === 'self' && (videoMp4Url || videoWebmUrl)){ const posterAttr = videoPosterUrl ? ` poster="${videoPosterUrl}"` : ''; const sources = [ videoMp4Url ? `` : '', videoWebmUrl ? `` : '' ].join(''); WATCH_HTML = `
`; } const STREAM_SECTION_HTML = streamButtons.length ? renderButtonsGrid(`Stream ${title}`, streamButtons, 'stream-section') : ''; const SUPPORT_SECTION_HTML = supportBandcamp ? renderButtonsGrid('Buy / Support', [{label:'Bandcamp', url:supportBandcamp}]) : ''; const FOLLOW_SECTION_HTML = renderButtonsGrid('Follow', followButtons); let html = TEMPLATE_HTML .replaceAll('{{INLINE_STYLE}}', STYLE_CSS) .replaceAll('{{INLINE_SCRIPT}}', SCRIPT_JS) .replaceAll('{{TITLE}}', escapeHtml(title)) .replaceAll('{{ARTIST}}', escapeHtml(artist)) .replaceAll('{{COVER_PATH}}', coverDataURL) .replaceAll('{{BUY_BUTTON_HTML}}', BUY_BUTTON_HTML) .replaceAll('{{STREAM_MAIN_BUTTON_HTML}}', STREAM_MAIN_BUTTON_HTML) .replaceAll('{{AUDIO_EMBED_HTML}}', AUDIO_EMBED_HTML) .replaceAll('{{DESCRIPTION_HTML}}', DESCRIPTION_HTML) .replaceAll('{{WATCH_HTML}}', WATCH_HTML) .replaceAll('{{SUPPORT_SECTION_HTML}}', SUPPORT_SECTION_HTML) .replaceAll('{{STREAM_SECTION_HTML}}', STREAM_SECTION_HTML) .replaceAll('{{FOLLOW_SECTION_HTML}}', FOLLOW_SECTION_HTML) .replaceAll('{{CREDIT_FOOTER}}', CREDIT_FOOTER); const blob = new Blob([html], {type:'text/html;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const slug = (title || 'release').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); a.href = url; a.download = `${slug}.html`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); $('#status').textContent = `Downloaded → ${slug}.html`; }catch(err){ console.error(err); $('#status').textContent = 'Generation failed (see console).'; alert('Generation failed. See console for details.'); } }); }); })();