Single HTML file that converts an XSPF playlist to extended M3U8 in the browser. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
242 lines
6.3 KiB
HTML
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>
|