xspf-to-m3u8/index.html
KaKi87 9b30a83779 Add XSPF to M3U8 converter page
Single HTML file that converts an XSPF playlist to extended M3U8
in the browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 20:05:52 +01:00

242 lines
6.3 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>XSPF to M3U8 converter</title>
<style>
:root {
color-scheme: light dark;
--fg: #1d1d1f;
--muted: #6e6e73;
--border: #d2d2d7;
--accent: #0a66c2;
--bg: #ffffff;
--panel: #f5f5f7;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #f5f5f7;
--muted: #a1a1a6;
--border: #3a3a3c;
--accent: #4aa3ff;
--bg: #1c1c1e;
--panel: #2c2c2e;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--fg);
background: var(--bg);
}
main {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 48px;
}
h1 {
font-size: 1.6rem;
margin: 0 0 8px;
}
p { margin: 0 0 16px; }
.muted { color: var(--muted); }
#drop {
display: block;
border: 2px dashed var(--border);
border-radius: 12px;
padding: 36px 16px;
text-align: center;
cursor: pointer;
background: var(--panel);
}
#drop.over { border-color: var(--accent); }
#drop input { display: none; }
#status {
min-height: 1.5em;
margin: 16px 0;
}
#status.error { color: #d70015; }
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
button {
font: inherit;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--fg);
cursor: pointer;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
#output {
display: none;
width: 100%;
height: 280px;
font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--fg);
resize: vertical;
}
</style>
</head>
<body>
<main>
<h1>XSPF to M3U8 converter</h1>
<p class="muted">Converts an XSPF playlist to an extended M3U8 playlist. Runs entirely in your browser; the file is never uploaded.</p>
<label id="drop">
<input id="file" type="file" accept=".xspf,application/xspf+xml,text/xml,application/xml">
<span>Drop an .xspf file here, or click to choose one.</span>
</label>
<p id="status" role="status"></p>
<div class="actions">
<button id="download" class="primary" disabled>Download .m3u8</button>
<button id="copy" disabled>Copy</button>
</div>
<textarea id="output" readonly spellcheck="false"></textarea>
</main>
<script>
const XSPF_NS = "http://xspf.org/ns/0/";
const drop = document.getElementById("drop");
const fileInput = document.getElementById("file");
const status = document.getElementById("status");
const output = document.getElementById("output");
const downloadButton = document.getElementById("download");
const copyButton = document.getElementById("copy");
let m3u = "";
let baseName = "playlist";
function setStatus(message, isError = false) {
status.textContent = message;
status.classList.toggle("error", isError);
}
function childText(element, localName) {
for (const child of element.children) {
if (child.localName === localName) {
return child.textContent.trim();
}
}
return "";
}
function xspfToM3u(xmlText) {
const doc = new DOMParser().parseFromString(xmlText, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new Error("The file is not valid XML.");
}
const root = doc.documentElement;
if (root.localName !== "playlist") {
throw new Error("The file is not an XSPF playlist.");
}
const tracks = root.getElementsByTagNameNS(XSPF_NS, "track");
const lines = ["#EXTM3U"];
let count = 0;
for (const track of tracks) {
const location = childText(track, "location");
if (!location) {
continue;
}
const title = childText(track, "title");
lines.push(`#EXTINF:-1,${title}`);
lines.push(location);
count++;
}
if (count === 0) {
throw new Error("No tracks with a location were found.");
}
return { text: lines.join("\n") + "\n", count, title: childText(root, "title") };
}
function convertFile(file) {
const reader = new FileReader();
reader.onload = () => {
try {
const result = xspfToM3u(reader.result);
m3u = result.text;
baseName = file.name.replace(/\.xspf$/i, "") || "playlist";
output.value = m3u;
output.style.display = "block";
downloadButton.disabled = false;
copyButton.disabled = false;
setStatus(`Converted ${result.count} tracks from ${file.name}.`);
} catch (error) {
m3u = "";
output.value = "";
output.style.display = "none";
downloadButton.disabled = true;
copyButton.disabled = true;
setStatus(error.message, true);
}
};
reader.onerror = () => setStatus("The file could not be read.", true);
reader.readAsText(file);
}
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) {
convertFile(fileInput.files[0]);
}
});
drop.addEventListener("dragover", (event) => {
event.preventDefault();
drop.classList.add("over");
});
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
drop.addEventListener("drop", (event) => {
event.preventDefault();
drop.classList.remove("over");
if (event.dataTransfer.files[0]) {
convertFile(event.dataTransfer.files[0]);
}
});
downloadButton.addEventListener("click", () => {
const blob = new Blob([m3u], { type: "audio/x-mpegurl" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${baseName}.m3u8`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
});
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(m3u);
setStatus("Copied the playlist to the clipboard.");
} catch {
output.select();
setStatus("Copy failed. The text is selected so you can copy it manually.", true);
}
});
</script>
</body>
</html>