This commit is contained in:
Eric Villnow 2025-10-03 21:26:38 -05:00
commit a796176a17
8 changed files with 947 additions and 0 deletions

66
cards.js Normal file
View file

@ -0,0 +1,66 @@
// components.js
const Cards = {
TrackCard: (trackList) => {
const items = trackList.map(track => `
<li class="list-group-item bg-dark py-4 text-light d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold text-capitalize">${track.name}</span>
</div>
<div>
<span class="me-3">2 credits</span>
<button class="btn btn-sm btn-outline-light" data-uri="${track.uri}" onclick="loadTrack(this)">
<i class="fa fa-play"></i>
</button>
</div>
</li>
`).join('');
return `
<div class="card jukebox-card bg-dark text-light">
<div class="card-header fs-4 fw-bolder text-uppercase">
All Songs <span class="fw-lighter fs-5">${trackList.length}</span>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush song-list">
${items}
</ul>
</div>
</div>
`;
},
PlaylistCard: (playlist) => `
<div class="card playlist-card">
<img src="${playlist.image || ''}" alt="${playlist.name}" class="playlist-art"/>
<div class="playlist-info">
<strong>${playlist.name}</strong>
<small>${playlist.tracks} tracks</small>
</div>
</div>
`,
ArtistCard: (artist) => `
<div class="card d-flex justify-content-end align-items-start px-5 text-align-center flex-column artist-card text-light">
<div class="fs-1 fw-bold text-uppercase">${artist.name}</div>
<hr class="w-100 mb-5">
<div class="fw-bold text-uppercase">
RELATED ARTISTS<br><br>
</div>
</div>
`,
AlbumCard: (album) => `
<div class="card album-card">
<img src="${album.image || ''}" alt="${album.title}" class="album-art"/>
<div class="album-info">
<strong>${album.title}</strong>
<small>${album.year || ''}</small>
</div>
</div>
`
};

73
credits.js Normal file
View file

@ -0,0 +1,73 @@
// credits.js
const Credits = (() => {
const STORAGE_KEY = "jukeboxCredits";
// Get credits from storage or initialize to 0
function getCredits() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? parseInt(stored, 10) : 0;
}
// Save credits back to storage
function setCredits(value) {
localStorage.setItem(STORAGE_KEY, value);
updateDisplay();
}
// Update the UI
function updateDisplay() {
const el = document.getElementById("creditDisplay");
if (el) {
el.textContent = getCredits();
}
const nextEl = document.getElementById("trackModalNextButton");
const queueEl = document.getElementById("trackModalQueueButton");
if(Credits.get()>=3){
nextEl.classList.remove('disabled');
}else{
nextEl.classList.add('disabled');
}
if(Credits.get()>=2){
queueEl.classList.remove('disabled');
document.getElementById('msg').innerHTML = "";
}else{
queueEl.classList.add('disabled');
document.getElementById('msg').innerHTML = "<span class='badge rounded-pill text-bg-danger'>No Moneys</span>";
}
}
// Public API
return {
init() {
updateDisplay();
},
add(amount = 1) {
const current = getCredits();
setCredits(current + amount);
},
remove(amount = 1) {
const current = getCredits();
if(amount > current){
return false;
}
setCredits(Math.max(0, current - amount)); // dont go negative
return true;
},
reset() {
setCredits(0);
},
get() {
return getCredits();
}
};
})();
// Initialize once DOM is ready
document.addEventListener("DOMContentLoaded", () => {
Credits.init();
});

418
index.html Normal file
View file

@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Touchscreen Jukebox</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="https://unpkg.com/kioskboard@2.3.0/dist/kioskboard-2.3.0.min.css" />
<style>
body, html {
height: 100%;
overflow: hidden; /* Prevent normal scrolling */
}
body {
transform: scale(0.8);
transform-origin: top left;
width: 125%; /* 1920 * 0.7 */
height: 125%;
}
.top-bar, .bottom-bar {
height: 120px; /* ~3x normal nav */
}
.main-body {
height: calc(100% - 240px); /* subtract top+bottom bar */
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
}
.jukebox-card {
flex: 0 0 40%;
margin: 5rem 1rem;
scroll-snap-align: start;
display: flex;
scroll-margin-left:1rem;
flex-direction: column;
}
.jukebox-card .card-body{
flex:1;
display:flex;
flex-direction:column;
overflow:hidden;
}
.artist-card {
flex: 0 0 50%;
margin: 5rem 1rem;
scroll-snap-align: start;
background:none;
scroll-margin-left:1rem;
border:none;
}
.song-list {
flex:1;
overflow-y: auto;
}
.bottom-bar .nav {
justify-content: center;
}
.bottom-bar .nav-link {
text-align: center;
color: white;
border: 1px solid white;
border-radius:0px;
}
.bottom-bar .nav-link i {
display: block;
font-size: 1.5rem;
}
.bg-dark{
background-color:#191919 !important;
}
.card{
border-radius:0px;
}
.main-body{
background: #000dff;
background:
linear-gradient(350deg, rgba(0, 13, 255, .5) 0%, rgba(255, 0, 157, .5) 80%),
linear-gradient(to right, rgba(0,0,0,0) 40vh, rgba(0,0,0,1) 75vh),
url("<?php echo $artist['images'][0]['url']; ?>") no-repeat;
background-size: contain;
background-blend-mode: multiply, destination-in, normal; /* control blending */
}
.credits{
border: 2px solid magenta;
border-radius: 50%;
font-size: 3em;
width:80px;
text-align: center;
font-weight: light;
}
.card.bg-dark,
.modal-content.bg-dark{
background-color: rgba(25, 25, 25, 0.8) !important;
}
.card > .card-header,
.modal-content > .modal-header{
background: #191919 !important;
}
li.bg-dark{
background:none!important;
}
.fs-1{
font-size: 5em !important;
}
.btn{
border-radius:0;
background: rgba(0,0,0,.8);
}
.modal-content{
border-radius:0;
}
.modal > .btn{
background: blue !important;
}
input.form-control{
border-radius:0;
}
/* make sure the container hides overflow */
#titlebox {
overflow: hidden;
position: relative;
white-space: nowrap;
}
/* when the text needs scrolling */
#titlebox.scrolling .text {
display: inline-block; /* creates spacing for the loop */
animation: marquee 10s linear infinite;
}
/* pause at the start before moving */
@keyframes marquee {
0% { transform: translateX(0); }
10% { transform: translateX(0); } /* pause for 10% of duration */
100% { transform: translateX(-100%); }
}
</style>
</head>
<body class="bg-dark text-light">
<!-- Top Bar -->
<div class="top-bar bg-dark d-flex justify-content-between align-items-center px-4">
<!-- Now Playing -->
<div class="d-flex align-items-center m-0">
<img src="https://placehold.co/120" id="nowPlayingArt" width="120px" class="me-3" alt="Now Playing">
<div>
<div class="fw-bold text-primary">Now Playing</div>
<div class="fw-bold" id="nowPlayingTrack"></div>
<div class="text-light fw-light" id="nowPlayingArtist"></div>
</div>
</div>
<div>
<input type="text" id="search" class="form-control text-light bg-dark js-kioskboard-input" data-kioskboard-type="keyboard" data-kioskboard-placement="bottom" data-kioskboard-specialcharacters="false">
</div>
<!-- Credits & Prices -->
<div class="text-end d-flex align-items-center">
<div class="credits fw-lighter me-3" id="creditDisplay">0</div>
<div class="vr me-3"></div>
<div>
<div class="text-light">2 for $1.00</div>
<div class="text-light">13 for $5.00</div>
</div>
</div>
</div>
<!-- Main Body -->
<div class="main-body">
<!-- Example Card -->
<!-- Add more cards horizontally -->
<div class="card jukebox-card bg-dark text-light">
<div class="card-header">Playlists (12)</div>
<div class="card-body d-flex align-items-center justify-content-center">
<p>Playlist content goes here</p>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar bg-dark">
<ul class="nav nav-pills h-100">
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-home"></i>Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-search"></i>Search</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-list"></i>Playlists</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#exampleModal"><i class="fa fa-heart"></i>Favorites</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="loadArtist()"><i class="fa fa-star"></i>Top Tracks</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#"><i class="fa fa-ellipsis-h"></i>More+</a>
</li>
</ul>
</div>
<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark">
<div class="modal-body d-flex">
<div class="flex-shrink-1 m-5">
<img id="trackModalArt" src="https://placehold.co/250" width="250px"><br><br><span class="fs-small"><small>&copy; 2011 COLUMBIA / LEGACY</small></span>
</div>
<div class="flex-grow-1 m-5 text-nowrap overflow-hidden">
<div id="titlebox" class="overflow-hidden text-nowrap">
<h2 class="fw-bold text" id="trackModalTitle"></h2>
</div>
<span id="trackModalArtist"></span> ><br>
<span class="fw-bold" id="trackModalAlbum"></span>
<hr>
<div class="d-flex mt-2">
<button class="btn flex-grow-1 w-50 btn-lg btn-primary" id="trackModalNextButton" onclick="queueNext(this,3)">Play Next<br><span class="badge rounded-pill text-bg-primary">3 Credits</span></button>
<button class="btn flex-grow-1 w-50 btn-lg btn-secondary" id="trackModalQueueButton" onclick="queueSong(this,2)">Queue Song<br><span class="badge rounded-pill text-bg-secondary">2 Credits</span></button>
</div>
<div id="msg">
</div>
</div>
</div>
<div class="modal-footer d-flex flex-column align-items-center">
<button type="button" class="btn btn-secondary text-uppercase" id="trackModalArtistButton">More from this artist ></button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/kioskboard@2.3.0/dist/kioskboard-2.3.0.min.js"></script>
<script src="./jsonws.js"></script>
<script src="./credits.js"></script>
<script src="./queue.js"></script>
<script src="./nowplaying.js"></script>
<script src="./cards.js"></script>
<script src="./templates.js"></script>
<script src="./viewManager.js"></script>
<script>
ViewManager.load(Templates.test);
//init WebSocket
const addr = "kiosk";
const client = new JsonRpcWsClient({
url: ("ws://" + addr + ':6680/mopidy/ws/'),
reconnect: true,
requestTimeout: 15000
});
var artistid = '6zFYqv1mOsgBRQbae3JJ9e';
var modalEl = document.getElementById("exampleModal");
var myModal = new bootstrap.Modal(modalEl);
function loadArtist(){
client
.call("core.library.browse",{uri:"spotify:artist:" + artistid})
.then(resultz => {
ViewManager.load(Templates.artist,resultz)
})
}
function loadTrack(el){
var uri = el.dataset.uri;
client.call("core.library.lookup",{"uris":[uri]}).then(trackRes =>{
var track = trackRes[uri][0];
console.dir("track: " + JSON.stringify(track));
const trackEl = document.getElementById("trackModalTitle");
const artistEl = document.getElementById("trackModalArtist");
const albumEl = document.getElementById("trackModalAlbum");
const buttonEl = document.getElementById("trackModalArtistButton");
if (trackEl) trackEl.textContent = track.name;
if (artistEl) artistEl.textContent = track.artists[0].name;
if (albumEl) artistEl.textContent = track.album.name;
client.call("core.library.get_images",{ uris: [track.album.uri] }).then(images => {
// Pick the first (largest?) image
const artUri = images[track.album.uri][0].uri;
const artEl = document.getElementById("trackModalArt");
if (artEl) artEl.src = artUri;
})
});
myModal.show();
}
client.on('notification', (notif) => {
console.log('JSON-RPC notification:', notif);
});
client.on('response', (r) => {
console.log('response event:', r);
});
client.on('message', (msg) => {
console.log('got plain JSON:', msg);
});
client.on('raw', (txt) => {
console.log('raw text:', txt);
});
client.on('error', (e) => console.error('ws error', e));
client.on('close', (ev) => console.log('closed', ev));
client.on('reconnect_scheduled', delay => console.log('reconnect in', delay, 'ms'));
// Start:
client.connect();
// Example: call after 2s (demonstrates queueing if not ready yet)
setTimeout(() => {
client.call('core.playback.get_time_position')
.then(pos => console.log('position', pos))
.catch(err => console.error('pos err', err));
}, 2000);
</script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const modal = document.getElementById("exampleModal");
const box = document.getElementById("titlebox");
const text = box.querySelector(".text");
function applyScrolling() {
box.classList.remove("scrolling");
text.style.paddingRight = "0px";
if (text.scrollWidth > box.clientWidth) {
const overflow = text.scrollWidth - box.clientWidth;
text.style.paddingRight = overflow + "px"; // dynamic gap
box.classList.add("scrolling");
}
}
// Recalculate once modal is fully visible
modal.addEventListener("shown.bs.modal", applyScrolling);
// Optional: recalc on window resize
window.addEventListener("resize", function () {
if (modal.classList.contains("show")) {
applyScrolling();
}
});
});
var usKeyboard = [
{
"0": "Q",
"1": "W",
"2": "E",
"3": "R",
"4": "T",
"5": "Y",
"6": "U",
"7": "I",
"8": "O",
"9": "P"
},
{
"0": "A",
"1": "S",
"2": "D",
"3": "F",
"4": "G",
"5": "H",
"6": "J",
"7": "K",
"8": "L"
},
{
"0": "Z",
"1": "X",
"2": "C",
"3": "V",
"4": "B",
"5": "N",
"6": "M"
}
];
KioskBoard.run('.js-kioskboard-input', {
keysArrayOfObjects:usKeyboard,
theme:"dark",
cssAnimations: true,
keysEnterText: 'Search',
keysAllowSpacebar: true,
allowRealKeyboard: true,
allowMobileKeyboard: true,
// ...init options
});
</script>
</body>
</html>

206
jsonws.js Normal file
View file

@ -0,0 +1,206 @@
/**
* Lightweight JSON-RPC over WebSocket client (vanilla JS).
*
* Usage:
* const client = new JsonRpcWsClient({ url: 'wss://vill.now/mopidy/ws/' });
* client.on('open', () => console.log('connected'));
* client.call('core.playback.get_state').then(result => console.log(result));
* client.on('notification', (notif) => console.log('notif', notif));
* client.connect();
*
* Works with wss:// if your page is https. If you pass ws:// on an https page,
* the browser will block it (mixed content). Use a reverse proxy or wss.
*/
class JsonRpcWsClient {
constructor(opts = {}) {
this.url = opts.url || this._defaultUrl(); // ws/wss endpoint
this.reconnect = opts.reconnect ?? true;
this.reconnectDelay = opts.reconnectDelay ?? 1000; // initial ms
this.maxReconnectDelay = opts.maxReconnectDelay ?? 30000; // max ms
this.requestTimeout = opts.requestTimeout ?? 15000; // ms
this._ws = null;
this._nextId = 1;
this._pending = new Map(); // id -> {resolve,reject,timeout}
this._listeners = new Map(); // event -> [fn,...]
this._sendQueue = []; // queue while disconnected
this._backoff = this.reconnectDelay;
this._manuallyClosed = false;
}
// ---------- public API ----------
connect() {
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return;
this._manuallyClosed = false;
this._openWebSocket();
}
disconnect() {
this._manuallyClosed = true;
if (this._ws) {
try { this._ws.close(1000, 'client disconnect'); } catch (e) {}
}
this._clearPending(new Error('Client disconnected'));
}
/**
* Send a JSON-RPC request. Returns a Promise which resolves with result or rejects on error/timeout.
* method: string, params: array or object or undefined.
*/
call(method, params) {
const id = this._nextId++;
const payload = { jsonrpc: '2.0', method, params, id };
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this._pending.delete(id);
reject(new Error('JSON-RPC request timed out'));
}, this.requestTimeout);
this._pending.set(id, { resolve, reject, timeout });
const str = JSON.stringify(payload);
if (this._isWsOpen()) {
this._ws.send(str);
} else {
// queue until connected
this._sendQueue.push(str);
}
});
}
/**
* Send a raw notification (no id expected).
*/
notify(method, params) {
const payload = { jsonrpc: '2.0', method, params };
const str = JSON.stringify(payload);
if (this._isWsOpen()) this._ws.send(str);
else this._sendQueue.push(str);
}
on(event, fn) {
if (!this._listeners.has(event)) this._listeners.set(event, []);
this._listeners.get(event).push(fn);
return () => this.off(event, fn);
}
off(event, fn) {
if (!this._listeners.has(event)) return;
this._listeners.set(event, this._listeners.get(event).filter(f => f !== fn));
}
// ---------- internal helpers ----------
_defaultUrl() {
// if page is https, prefer wss, else ws.
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
// default path used by Mopidy is often /mopidy/ws/ — but we don't assume that; user can pass url.
return `${proto}//${loc.host}/mopidy/ws/`;
}
_emit(event, ...args) {
const list = this._listeners.get(event) || [];
for (const fn of list.slice()) { try { fn(...args); } catch (e) { console.error('listener error', e); } }
}
_openWebSocket() {
try {
this._ws = new WebSocket(this.url);
} catch (err) {
// e.g. invalid URL or blocked
this._emit('error', err);
this._scheduleReconnect();
return;
}
this._ws.addEventListener('open', () => {
this._backoff = this.reconnectDelay;
this._emit('open');
// flush queue
while (this._sendQueue.length && this._isWsOpen()) {
this._ws.send(this._sendQueue.shift());
}
});
this._ws.addEventListener('message', (ev) => this._handleMessage(ev.data));
this._ws.addEventListener('close', (ev) => {
this._emit('close', ev);
// reject pending requests
this._clearPending(new Error(`WebSocket closed (code=${ev.code})`));
if (!this._manuallyClosed && this.reconnect) this._scheduleReconnect();
});
this._ws.addEventListener('error', (err) => {
this._emit('error', err);
// errors are followed by close; clear pending on close
});
}
_isWsOpen() {
return this._ws && this._ws.readyState === WebSocket.OPEN;
}
_scheduleReconnect() {
if (!this.reconnect) return;
const delay = this._backoff;
this._emit('reconnect_scheduled', delay);
setTimeout(() => {
if (this._manuallyClosed) return;
this._openWebSocket();
}, delay);
this._backoff = Math.min(this._backoff * 1.8, this.maxReconnectDelay);
}
_clearPending(err) {
for (const [id, p] of this._pending) {
clearTimeout(p.timeout);
p.reject(err);
}
this._pending.clear();
}
_handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch (err) {
// not JSON at all
this._emit('raw', raw);
return;
}
// JSON-RPC response (has id)
if (msg && Object.prototype.hasOwnProperty.call(msg, 'id')) {
const id = msg.id;
const pending = this._pending.get(id);
if (pending) {
clearTimeout(pending.timeout);
this._pending.delete(id);
if (msg.error !== undefined) {
pending.reject(msg.error);
this._emit('response', { id, error: msg.error });
} else {
pending.resolve(msg.result);
this._emit('response', { id, result: msg.result });
}
} else {
// response to unknown request
this._emit('response', msg);
}
return;
}
// JSON-RPC notification (has method but no id)
if (msg && Object.prototype.hasOwnProperty.call(msg, 'method')) {
this._emit('notification', msg);
return;
}
// Fallback: just emit as plain message
this._emit('message', msg);
}
}

84
nowplaying.js Normal file
View file

@ -0,0 +1,84 @@
// nowplaying.js
const NowPlaying = (() => {
function renderTrack(track) {
if (!track) return;
const trackName = track.name || "Unknown Track";
const artistName = (track.artists && track.artists.length > 0)
? track.artists[0].name
: "Unknown Artist";
const albumUri = track.album ? track.album.uri : null;
// Update text elements
const trackEl = document.getElementById("nowPlayingTrack");
const artistEl = document.getElementById("nowPlayingArtist");
if (trackEl) trackEl.textContent = trackName;
if (artistEl) artistEl.textContent = artistName;
// Get album art
if (albumUri) {
client.call("core.library.get_images", { uris: [albumUri] })
.then(images => {
if (
images &&
images[albumUri] &&
images[albumUri].length > 0
) {
// Pick the first (largest?) image
const artUri = images[albumUri][0].uri;
const artEl = document.getElementById("nowPlayingArt");
if (artEl) artEl.src = artUri;
}
})
.catch(err => console.error("Image load error:", err));
}
}
function init() {
// On WS open, fetch current track
client.on("open", () => {
client
.call("core.playback.get_current_track")
.then(track => {
console.log("Initial now playing:", track);
renderTrack(track);
})
.catch(err => console.error("get_current_track error:", err));
});
// Listen for events on the WS
client.on("message", msg => {
if (!msg.event) return; // ignore non-event JSON
switch (msg.event) {
case "track_playback_started":
if (msg.tl_track && msg.tl_track.track) {
console.log("Now playing:", msg.tl_track.track);
renderTrack(msg.tl_track.track);
}
break;
case "track_playback_ended":
console.log("Track ended:", msg.tl_track);
break;
case "playback_state_changed":
console.log(
`Playback state changed: ${msg.old_state}${msg.new_state}`
);
break;
default:
// Other events are ignored for now
break;
}
});
}
return { init };
})();
// Auto-init after DOM loads
document.addEventListener("DOMContentLoaded", () => {
NowPlaying.init();
});

18
queue.js Normal file
View file

@ -0,0 +1,18 @@
const Queue = (() => {
});
function queueSong(song, cost) {
if (Credits.remove(cost)) {
//songQueue.push(song);
console.log(`Queued at end: ${song}`);
}
}
function queueNext(song, cost) {
if (Credits.remove(cost)) {
// Insert after current song (assume index 0 = currently playing)
//songQueue.splice(1, 0, song);
console.log(`Queued next: ${song}`);
}
}

57
templates.js Normal file
View file

@ -0,0 +1,57 @@
// templates.js
const Templates = {
test: ()=>`
<div class="card artist-card">
<h1>Test</h1>
</div>
`,
home: (nowPlaying, upNext=[]) => `
<div class="card nowplaying-card">
<h2>Now Playing</h2>
${Cards.TrackCard(nowPlaying)}
</div>
<div class="card upnext-card">
<h3>Up Next</h3>
<div class="upnext-list">
${upNext.map(t => Cards.TrackCard(t)).join("")}
</div>
</div>
`,
search: (results) => `
<div class="card search-card">
<h2>Search Results</h2>
<div class="search-results">
${results.map(r => {
if (r.type === "artist") return Cards.ArtistCard(r);
if (r.type === "playlist") return Cards.PlaylistCard(r);
if (r.type === "track") return Cards.TrackCard(r);
return "";
}).join("")}
</div>
</div>
`,
artist: (trackList) => `
${Cards.TrackCard(trackList)}
`,
playlists: (playlists) => `
<div class="card playlists-card">
<h2>Playlists</h2>
<div class="playlist-list">
${playlists.map(p => Cards.PlaylistCard(p)).join("")}
</div>
</div>
`,
modal: (track) => `
<div class="modal-content">
<h2>${track.name}</h2>
<p>${track.artist} <em>${track.album}</em></p>
<button onclick="queueTrack('${track.uri}')">Add to Queue</button>
<button onclick="playNext('${track.uri}')">Play Next</button>
</div>
`
};

25
viewManager.js Normal file
View file

@ -0,0 +1,25 @@
// viewManager.js
const ViewManager = (() => {
const mainBody = document.querySelector(".main-body");
const modal = document.getElementById("trackModal");
function load(templateFn, data) {
if (!mainBody) return;
mainBody.innerHTML = templateFn(data);
}
function showModal(templateFn, data) {
if (!modal) return;
modal.innerHTML = templateFn(data);
modal.style.display = "block";
}
function hideModal() {
if (!modal) return;
modal.style.display = "none";
modal.innerHTML = "";
}
return { load, showModal, hideModal };
})();