<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Kvasir</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 16px/1.45 "Segoe UI", system-ui, sans-serif;
background: #f6f1e8;
color: #2a241c;
}
header, main, footer { padding: 20px 24px; }
header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
h1 { margin: 0; font-size: 28px; font-weight: 650; letter-spacing: -0.03em; }
p.lede { margin: 4px 0 0; color: #6d6256; }
button, select, input {
font: inherit;
border-radius: 10px;
border: 1px solid #d9cfc2;
background: white;
color: inherit;
}
button { padding: 10px 14px; cursor: pointer; }
button.primary { background: #2a241c; color: #f6f1e8; border-color: #2a241c; }
button:disabled { opacity: 0.45; cursor: default; }
.row { display: flex; gap: 8px; }
input[type="text"] { flex: 1; padding: 12px 14px; }
#results { display: flex; flex-direction: column; gap: 8px; }
article {
display: flex; justify-content: space-between; gap: 12px; align-items: center;
background: white; border-radius: 12px; padding: 12px 14px;
}
article strong { display: block; }
article span { color: #6d6256; font-size: 14px; }
footer { display: grid; gap: 10px; }
label { display: flex; gap: 8px; align-items: center; }
#folder { flex: 1; }
#status { min-height: 1.4em; color: #6d6256; }
#status.ok { color: #1f7a45; }
#status.err { color: #9d2c2c; }
.hidden { display: none; }
</style>
</head>
<body>
<header>
<div>
<h1>Kvasir</h1>
<p class="lede" id="account">Not signed in</p>
</div>
<div class="row">
<button id="signin" class="primary" type="button">Sign in with Deezer</button>
<button id="signout" class="hidden" type="button">Sign out</button>
</div>
</header>
<main>
<form id="go" class="row">
<input id="query" type="text" placeholder="Search, or paste a Deezer, Spotify, Tidal, or YouTube link" autocomplete="off">
<button class="primary" type="submit">Go</button>
</form>
<div id="results" style="margin-top: 16px"></div>
</main>
<footer>
<label>Quality
<select id="quality">
<option value="128">128 kbps</option>
<option value="320">320 kbps</option>
<option value="flac">FLAC</option>
</select>
</label>
<div class="row">
<input id="folder" type="text" spellcheck="false">
<button id="browse" type="button">Choose folder</button>
</div>
<div id="status"></div>
<p class="lede" id="configdir"></p>
<div>
<button id="reset" type="button">Delete saved login and start over</button>
</div>
</footer>
<script>
const send = (msg) => window.ipc.postMessage(JSON.stringify(msg));
const $ = (id) => document.getElementById(id);
function setBusy(on) {
document.body.classList.toggle("busy", on);
for (const el of document.querySelectorAll("button, input, select")) el.disabled = on && el.id !== "reset";
}
function setStatus(level, text) {
const node = $("status");
node.className = level || "";
node.textContent = text || "";
}
window.kvasir = window.kvasir || {};
window.kvasir.send = send;
window.kvasir.recv = (msg) => {
if (msg.op === "state") {
$("account").textContent = msg.signed_in ? ("Signed in" + (msg.name ? " as " + msg.name : "")) : "Not signed in";
$("signin").classList.toggle("hidden", msg.signed_in);
$("signout").classList.toggle("hidden", !msg.signed_in);
$("quality").value = ["128", "320", "flac"].includes(msg.quality) ? msg.quality : "flac";
$("folder").value = msg.download_dir || "";
$("configdir").textContent = "Settings stay in " + msg.config_dir + ". Close the window and run kvasir reset to delete that folder.";
} else if (msg.op === "results") {
const box = $("results");
box.replaceChildren();
if (!msg.tracks || !msg.tracks.length) {
setStatus("err", "Nothing matched.");
return;
}
setStatus("ok", msg.tracks.length + " track(s). Download one, or paste a link into the box.");
for (const track of msg.tracks) {
const row = document.createElement("article");
const text = document.createElement("div");
const title = document.createElement("strong");
title.textContent = track.title + " — " + track.artist;
const note = document.createElement("span");
note.textContent = track.album + " · " + track.duration;
text.append(title, note);
const button = document.createElement("button");
button.type = "button";
button.textContent = "Download";
button.addEventListener("click", () => send({ op: "download", id: track.id }));
row.append(text, button);
box.append(row);
}
} else if (msg.op === "downloaded") {
const paths = msg.paths || [];
setStatus("ok", paths.length ? ("Saved " + paths[paths.length - 1]) : "Nothing was written.");
} else if (msg.op === "status") {
setStatus(msg.level, msg.text);
} else if (msg.op === "busy") {
setBusy(!!msg.on);
if (msg.on) setStatus("info", "Working…");
}
};
$("signin").addEventListener("click", () => send({ op: "login" }));
$("signout").addEventListener("click", () => send({ op: "logout" }));
$("reset").addEventListener("click", () => send({ op: "reset" }));
$("browse").addEventListener("click", () => send({ op: "browse" }));
$("quality").addEventListener("change", () => send({ op: "settings", quality: $("quality").value, download_dir: $("folder").value }));
$("folder").addEventListener("change", () => send({ op: "settings", quality: $("quality").value, download_dir: $("folder").value }));
$("go").addEventListener("submit", (event) => {
event.preventDefault();
send({ op: "go", query: $("query").value });
});
send({ op: "ready" });
</script>
</body>
</html>