commit a796176a175500cad0d28ddd88585eddaa211bd1 Author: Eric Villnow Date: Fri Oct 3 21:26:38 2025 -0500 init 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 }; +})();