From a796176a175500cad0d28ddd88585eddaa211bd1 Mon Sep 17 00:00:00 2001 From: Eric Villnow Date: Fri, 3 Oct 2025 21:26:38 -0500 Subject: [PATCH] init --- cards.js | 66 ++++++++ credits.js | 73 +++++++++ index.html | 418 +++++++++++++++++++++++++++++++++++++++++++++++++ jsonws.js | 206 ++++++++++++++++++++++++ nowplaying.js | 84 ++++++++++ queue.js | 18 +++ templates.js | 57 +++++++ viewManager.js | 25 +++ 8 files changed, 947 insertions(+) create mode 100644 cards.js create mode 100644 credits.js create mode 100644 index.html create mode 100644 jsonws.js create mode 100644 nowplaying.js create mode 100644 queue.js create mode 100644 templates.js create mode 100644 viewManager.js diff --git a/cards.js b/cards.js new file mode 100644 index 0000000..5d7e643 --- /dev/null +++ b/cards.js @@ -0,0 +1,66 @@ +// components.js +const Cards = { + + + + TrackCard: (trackList) => { + const items = trackList.map(track => ` +
  • +
    + ${track.name} +
    +
    + 2 credits + +
    +
  • + `).join(''); + + return ` +
    +
    + All Songs ${trackList.length} +
    +
    +
      + ${items} +
    +
    +
    + `; + }, + + PlaylistCard: (playlist) => ` +
    + ${playlist.name} +
    + ${playlist.name} + ${playlist.tracks} tracks +
    +
    + `, + + ArtistCard: (artist) => ` +
    +
    ${artist.name}
    + +
    +
    + RELATED ARTISTS

    + +
    +
    + `, + + AlbumCard: (album) => ` +
    + ${album.title} +
    + ${album.title} + ${album.year || ''} +
    +
    + ` +}; diff --git a/credits.js b/credits.js new file mode 100644 index 0000000..42e1428 --- /dev/null +++ b/credits.js @@ -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 = "No Moneys"; + } + + } + + // 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(); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..b592283 --- /dev/null +++ b/index.html @@ -0,0 +1,418 @@ + + + + + Touchscreen Jukebox + + + + + + + + + + + +
    + +
    + Now Playing +
    +
    Now Playing
    +
    +
    +
    +
    +
    + + +
    + +
    +
    0
    +
    +
    +
    2 for $1.00
    +
    13 for $5.00
    +
    +
    +
    + + +
    + + + + +
    +
    Playlists (12)
    +
    +

    Playlist content goes here

    +
    +
    +
    + + +
    + +
    + + + + + + + + + + + + + + + + + + + + diff --git a/jsonws.js b/jsonws.js new file mode 100644 index 0000000..810aede --- /dev/null +++ b/jsonws.js @@ -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); + } +} + diff --git a/nowplaying.js b/nowplaying.js new file mode 100644 index 0000000..a72373a --- /dev/null +++ b/nowplaying.js @@ -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(); +}); diff --git a/queue.js b/queue.js new file mode 100644 index 0000000..a75d4dc --- /dev/null +++ b/queue.js @@ -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}`); + } + } \ No newline at end of file diff --git a/templates.js b/templates.js new file mode 100644 index 0000000..0329fc5 --- /dev/null +++ b/templates.js @@ -0,0 +1,57 @@ +// templates.js +const Templates = { + test: ()=>` +
    +

    Test

    +
    + `, + + home: (nowPlaying, upNext=[]) => ` +
    +

    Now Playing

    + ${Cards.TrackCard(nowPlaying)} +
    +
    +

    Up Next

    +
    + ${upNext.map(t => Cards.TrackCard(t)).join("")} +
    +
    + `, + + search: (results) => ` +
    +

    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("")} +
    +
    + `, + + artist: (trackList) => ` + ${Cards.TrackCard(trackList)} + `, + + playlists: (playlists) => ` +
    +

    Playlists

    +
    + ${playlists.map(p => Cards.PlaylistCard(p)).join("")} +
    +
    + `, + + modal: (track) => ` + + ` +}; diff --git a/viewManager.js b/viewManager.js new file mode 100644 index 0000000..542ab40 --- /dev/null +++ b/viewManager.js @@ -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 }; +})();