300 lines
9.7 KiB
JavaScript
300 lines
9.7 KiB
JavaScript
/* VibeSonic — theme toggle + bottom player. Deliberately minimal
|
|
* vanilla JS: no bundler, no framework. */
|
|
(function () {
|
|
"use strict";
|
|
|
|
// --- Theme toggle ---
|
|
function initTheme() {
|
|
var toggle = document.querySelector("[data-theme-toggle]");
|
|
if (!toggle) return;
|
|
toggle.addEventListener("click", function () {
|
|
var root = document.documentElement;
|
|
var current = root.getAttribute("data-bs-theme") || "dark";
|
|
var next = current === "dark" ? "light" : "dark";
|
|
root.setAttribute("data-bs-theme", next);
|
|
try {
|
|
localStorage.setItem("vs-theme", next);
|
|
} catch (e) {
|
|
/* storage may be unavailable (private browsing); theme just won't persist */
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Player ---
|
|
function VibeSonicPlayer(audioEl, ui) {
|
|
this.audio = audioEl;
|
|
this.ui = ui;
|
|
this.queue = [];
|
|
this.index = -1;
|
|
this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this));
|
|
this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this));
|
|
this.audio.addEventListener("ended", this.next.bind(this));
|
|
this.audio.addEventListener("play", this._syncPlayIcon.bind(this));
|
|
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
|
}
|
|
|
|
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
|
this.queue = songs;
|
|
this.index = startIndex || 0;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._loadCurrent = function () {
|
|
var song = this.queue[this.index];
|
|
if (!song) return;
|
|
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
|
this.ui.title.textContent = song.title || "";
|
|
this.ui.artist.textContent = song.artist || "";
|
|
this.ui.art.src = song.cover ? "/cover-art/" + encodeURIComponent(song.cover) + "?size=100" : "/static/img/default-art.svg";
|
|
this.ui.starBtn.dataset.itemType = "song";
|
|
this.ui.starBtn.dataset.itemId = song.id;
|
|
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
|
this.ui.starBtn.innerHTML = song.starred ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
|
|
|
if ("mediaSession" in navigator) {
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: song.title || "",
|
|
artist: song.artist || "",
|
|
album: song.album || "",
|
|
});
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.playPause = function () {
|
|
if (!this.queue.length) return;
|
|
if (this.audio.paused) {
|
|
this.audio.play().catch(function () {});
|
|
} else {
|
|
this.audio.pause();
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.next = function () {
|
|
if (this.index + 1 < this.queue.length) {
|
|
this.index++;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.prev = function () {
|
|
if (this.audio.currentTime > 3) {
|
|
this.audio.currentTime = 0;
|
|
return;
|
|
}
|
|
if (this.index > 0) {
|
|
this.index--;
|
|
this._loadCurrent();
|
|
this.audio.play().catch(function () {});
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
|
if (this.audio.duration) {
|
|
this.audio.currentTime = fraction * this.audio.duration;
|
|
}
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.seekBy = function (deltaSeconds) {
|
|
this.audio.currentTime = Math.max(0, this.audio.currentTime + deltaSeconds);
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.setVolume = function (v) {
|
|
this.audio.volume = Math.min(1, Math.max(0, v));
|
|
this.ui.volumeSlider.value = this.audio.volume;
|
|
};
|
|
|
|
VibeSonicPlayer.prototype.toggleMute = function () {
|
|
this.audio.muted = !this.audio.muted;
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._syncPlayIcon = function () {
|
|
this.ui.playBtn.innerHTML = this.audio.paused
|
|
? '<i class="bi bi-play-fill"></i>'
|
|
: '<i class="bi bi-pause-fill"></i>';
|
|
};
|
|
|
|
VibeSonicPlayer.prototype._onTimeUpdate = function () {
|
|
var cur = this.audio.currentTime || 0;
|
|
var dur = this.audio.duration || 0;
|
|
this.ui.currentTime.textContent = formatTime(cur);
|
|
this.ui.duration.textContent = formatTime(dur);
|
|
if (dur > 0) {
|
|
this.ui.progress.value = String((cur / dur) * 100);
|
|
}
|
|
};
|
|
|
|
function formatTime(seconds) {
|
|
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
|
var m = Math.floor(seconds / 60);
|
|
var s = Math.floor(seconds % 60);
|
|
return m + ":" + (s < 10 ? "0" : "") + s;
|
|
}
|
|
|
|
function collectQueueFrom(el) {
|
|
var container = el.closest("[data-playable-list]") || document;
|
|
var nodes = container.querySelectorAll(".vs-playable");
|
|
var songs = [];
|
|
var startIndex = 0;
|
|
nodes.forEach(function (node, i) {
|
|
if (node === el || node.contains(el)) startIndex = i;
|
|
songs.push({
|
|
id: node.dataset.songId,
|
|
title: node.dataset.title,
|
|
artist: node.dataset.artist,
|
|
album: node.dataset.album,
|
|
cover: node.dataset.cover,
|
|
starred: node.dataset.starred === "1",
|
|
});
|
|
});
|
|
return { songs: songs, startIndex: startIndex };
|
|
}
|
|
|
|
function initPlayer() {
|
|
var audio = document.getElementById("vs-audio");
|
|
if (!audio) return null;
|
|
|
|
var ui = {
|
|
art: document.getElementById("vs-player-art"),
|
|
title: document.getElementById("vs-player-title"),
|
|
artist: document.getElementById("vs-player-artist"),
|
|
playBtn: document.getElementById("vs-play-btn"),
|
|
currentTime: document.getElementById("vs-current-time"),
|
|
duration: document.getElementById("vs-duration"),
|
|
progress: document.getElementById("vs-progress"),
|
|
volumeSlider: document.getElementById("vs-volume"),
|
|
starBtn: document.getElementById("vs-now-star"),
|
|
};
|
|
var player = new VibeSonicPlayer(audio, ui);
|
|
|
|
document.getElementById("vs-play-btn").addEventListener("click", function () {
|
|
player.playPause();
|
|
});
|
|
document.getElementById("vs-next-btn").addEventListener("click", function () {
|
|
player.next();
|
|
});
|
|
document.getElementById("vs-prev-btn").addEventListener("click", function () {
|
|
player.prev();
|
|
});
|
|
ui.progress.addEventListener("input", function () {
|
|
player.seekTo(Number(ui.progress.value) / 100);
|
|
});
|
|
ui.volumeSlider.addEventListener("input", function () {
|
|
player.setVolume(Number(ui.volumeSlider.value));
|
|
});
|
|
document.getElementById("vs-mute-btn").addEventListener("click", function () {
|
|
player.toggleMute();
|
|
});
|
|
|
|
document.addEventListener("click", function (ev) {
|
|
var el = ev.target.closest(".vs-playable");
|
|
if (!el) return;
|
|
ev.preventDefault();
|
|
var q = collectQueueFrom(el);
|
|
player.playQueue(q.songs, q.startIndex);
|
|
});
|
|
|
|
if ("mediaSession" in navigator) {
|
|
navigator.mediaSession.setActionHandler("play", function () { player.playPause(); });
|
|
navigator.mediaSession.setActionHandler("pause", function () { player.playPause(); });
|
|
navigator.mediaSession.setActionHandler("previoustrack", function () { player.prev(); });
|
|
navigator.mediaSession.setActionHandler("nexttrack", function () { player.next(); });
|
|
}
|
|
|
|
player.setVolume(1);
|
|
return player;
|
|
}
|
|
|
|
function isTypingTarget(el) {
|
|
if (!el) return false;
|
|
var tag = el.tagName;
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
|
}
|
|
|
|
function initKeyboardShortcuts(player) {
|
|
document.addEventListener("keydown", function (ev) {
|
|
if (isTypingTarget(document.activeElement)) return;
|
|
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
|
|
switch (ev.key) {
|
|
case " ":
|
|
case "k":
|
|
case "K":
|
|
ev.preventDefault();
|
|
if (player) player.playPause();
|
|
break;
|
|
case "j":
|
|
case "J":
|
|
if (player) player.prev();
|
|
break;
|
|
case "l":
|
|
case "L":
|
|
if (player) player.next();
|
|
break;
|
|
case "ArrowLeft":
|
|
if (player) player.seekBy(-5);
|
|
break;
|
|
case "ArrowRight":
|
|
if (player) player.seekBy(5);
|
|
break;
|
|
case "ArrowUp":
|
|
ev.preventDefault();
|
|
if (player) player.setVolume(player.audio.volume + 0.1);
|
|
break;
|
|
case "ArrowDown":
|
|
ev.preventDefault();
|
|
if (player) player.setVolume(player.audio.volume - 0.1);
|
|
break;
|
|
case "m":
|
|
case "M":
|
|
if (player) player.toggleMute();
|
|
break;
|
|
case "/":
|
|
var search = document.getElementById("vs-search-input");
|
|
if (search) {
|
|
ev.preventDefault();
|
|
search.focus();
|
|
}
|
|
break;
|
|
case "?":
|
|
var helpModalEl = document.getElementById("vs-shortcuts-modal");
|
|
if (helpModalEl && window.bootstrap) {
|
|
new window.bootstrap.Modal(helpModalEl).show();
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
function initStarButtons() {
|
|
document.addEventListener("click", function (ev) {
|
|
var btn = ev.target.closest("[data-star-toggle]");
|
|
if (!btn) return;
|
|
ev.preventDefault();
|
|
var itemType = btn.dataset.itemType;
|
|
var itemId = btn.dataset.itemId;
|
|
var active = btn.classList.contains("active");
|
|
var form = document.createElement("form");
|
|
form.method = "POST";
|
|
form.action = active ? "/favorites/unstar" : "/favorites/star";
|
|
form.style.display = "none";
|
|
[["item_type", itemType], ["item_id", itemId], ["csrf_token", window.vsCSRFToken || ""], ["next", location.pathname]].forEach(function (pair) {
|
|
var input = document.createElement("input");
|
|
input.name = pair[0];
|
|
input.value = pair[1];
|
|
form.appendChild(input);
|
|
});
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initTheme();
|
|
var player = initPlayer();
|
|
initKeyboardShortcuts(player);
|
|
initStarButtons();
|
|
});
|
|
})();
|