init
This commit is contained in:
commit
a796176a17
8 changed files with 947 additions and 0 deletions
66
cards.js
Normal file
66
cards.js
Normal 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
73
credits.js
Normal 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)); // don’t 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
418
index.html
Normal 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>© 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
206
jsonws.js
Normal 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
84
nowplaying.js
Normal 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
18
queue.js
Normal 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
57
templates.js
Normal 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
25
viewManager.js
Normal 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 };
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue