/
/
/
1<!DOCTYPE html>
2<html>
3<head>
4 <title>Music Assistant MSX Plugin</title>
5 <script src="/msx/tvx-plugin-module.min.js"></script>
6</head>
7<body>
8<script>
9(function() {
10 var BRIDGE = window.location.protocol + "//" + window.location.host;
11 var WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:";
12 var WS_URL = WS_PROTOCOL + "//" + window.location.host + "/ws";
13
14 function addDeviceParam(url, deviceId) {
15 if (!deviceId) return url;
16 var sep = url.indexOf("?") >= 0 ? "&" : "?";
17 return url + sep + "device_id=" + encodeURIComponent(deviceId);
18 }
19
20 function buildSearchRequestAction(deviceId) {
21 var searchUrl = BRIDGE + "/msx/search-input.json?q={INPUT}";
22 if (deviceId) searchUrl += "&device_id=" + encodeURIComponent(deviceId);
23 var inputPluginUrl = BRIDGE + "/msx/input.html";
24 if (deviceId) inputPluginUrl += "?device_id=" + encodeURIComponent(deviceId);
25 return "request:interaction:" + searchUrl + "|search:3|en|Search Music||||Search..." + "@" + inputPluginUrl;
26 }
27
28 function buildMenu(deviceId) {
29 return [
30 { icon: "history", label: "Recently played", data: addDeviceParam(BRIDGE + "/msx/recently-played.json", deviceId) },
31 { icon: "album", label: "Albums", data: addDeviceParam(BRIDGE + "/msx/albums.json", deviceId) },
32 { icon: "person", label: "Artists", data: addDeviceParam(BRIDGE + "/msx/artists.json", deviceId) },
33 { icon: "queue-music", label: "Playlists", data: addDeviceParam(BRIDGE + "/msx/playlists.json", deviceId) },
34 { icon: "audiotrack", label: "Tracks", data: addDeviceParam(BRIDGE + "/msx/tracks.json", deviceId) },
35 { icon: "search", label: "Search", data: buildSearchRequestAction(deviceId) },
36 // Static entry: the page itself explains when no party is active
37 { icon: "qr-code", label: "Party", data: addDeviceParam(BRIDGE + "/msx/party.json", deviceId) }
38 ];
39 }
40
41 function formatDuration(seconds) {
42 if (seconds == null || !isFinite(seconds) || seconds < 0) return "";
43 var m = Math.floor(seconds / 60);
44 var s = Math.floor(seconds % 60);
45 return m + ":" + (s < 10 ? "0" : "") + s;
46 }
47
48 function resolveAction(action) {
49 if (!action) return null;
50 if (action.indexOf("request:interaction:/") === 0) {
51 return action.replace("request:interaction:/", "request:interaction:" + BRIDGE + "/");
52 }
53 if (action.indexOf("/") === 0) {
54 return BRIDGE + action;
55 }
56 return action;
57 }
58
59 var wsRetryDelay = 1000;
60 var wsConnection = null;
61 var positionTimer = null;
62 var positionStartTime = 0;
63 var positionStartValue = 0;
64 var pausedByServer = false;
65 var resumedByServer = false;
66 var pausedAtPosition = 0;
67 var monitoredElement = null;
68 var mediaObserver = null;
69
70 function stopPositionTimer() {
71 if (positionTimer) {
72 clearInterval(positionTimer);
73 positionTimer = null;
74 }
75 }
76
77 function startPositionTimer(startPosition) {
78 stopPositionTimer();
79 positionStartValue = startPosition || 0;
80 positionStartTime = Date.now() / 1000;
81 positionTimer = setInterval(function() {
82 if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
83 var position;
84 if (monitoredElement && !monitoredElement.paused) {
85 position = monitoredElement.currentTime;
86 } else {
87 var elapsed = (Date.now() / 1000) - positionStartTime;
88 position = positionStartValue + elapsed;
89 }
90 try {
91 wsConnection.send(JSON.stringify({ type: "position", position: position }));
92 } catch (e) {
93 if (typeof console !== "undefined" && console.warn) {
94 console.warn("WebSocket position send failed", e);
95 }
96 }
97 }
98 }, 3000);
99 }
100
101 function connectPushWebSocket(deviceId) {
102 if (typeof WebSocket === "undefined") return null;
103 var url = deviceId ? WS_URL + "?device_id=" + encodeURIComponent(deviceId) : WS_URL;
104 var ws = new WebSocket(url);
105 var thisConnection = ws;
106 wsConnection = ws;
107 ws.onopen = function() { wsRetryDelay = 1000; };
108 ws.onmessage = function(ev) {
109 try {
110 var msg = JSON.parse(ev.data);
111 if (msg.type === "stop" && typeof tvx.InteractionPlugin.executeAction === "function") {
112 stopPositionTimer();
113 var action = msg.showNotification ? "eject" : "[player:eject|player:hide]";
114 tvx.InteractionPlugin.executeAction(action);
115 } else if (msg.type === "pause" && typeof tvx.InteractionPlugin.executeAction === "function") {
116 pausedAtPosition = monitoredElement ? monitoredElement.currentTime : getCurrentPosition();
117 stopPositionTimer();
118 pausedByServer = true;
119 tvx.InteractionPlugin.executeAction("player:pause");
120 } else if (msg.type === "resume" && typeof tvx.InteractionPlugin.executeAction === "function") {
121 startPositionTimer(pausedAtPosition);
122 resumedByServer = true;
123 tvx.InteractionPlugin.executeAction("player:play");
124 } else if (msg.type === "play" && msg.path && typeof tvx.InteractionPlugin.executeAction === "function") {
125 var playerLabel = msg.title || "Music Assistant";
126 var labelParts = [];
127 if (msg.artist) labelParts.push(msg.artist);
128 var dur = formatDuration(msg.duration);
129 if (dur) labelParts.push(dur);
130 var label = labelParts.join(" · ") || "";
131 var data = { playerLabel: playerLabel };
132 if (label) data.label = label;
133 if (msg.image_url) data.background = msg.image_url;
134 if (msg.duration != null && msg.duration > 0) data.duration = msg.duration;
135 if (msg.next_action) data.nextAction = resolveAction(msg.next_action);
136 if (msg.prev_action) data.prevAction = resolveAction(msg.prev_action);
137 startPositionTimer(0);
138
139 var audioUrl = BRIDGE + msg.path;
140 var audioAction = "audio:" + audioUrl;
141 tvx.InteractionPlugin.executeAction(audioAction, data);
142 } else if (msg.type === "playlist" && msg.url && typeof tvx.InteractionPlugin.executeAction === "function") {
143 var playlistUrl = BRIDGE + addDeviceParam(msg.url, deviceId);
144 startPositionTimer(0);
145 tvx.InteractionPlugin.executeAction("playlist:" + playlistUrl);
146 } else if (msg.type === "sendspin" && msg.url && typeof tvx.InteractionPlugin.executeAction === "function") {
147 // Sendspin bridge stream start: open the web kiosk in Sendspin
148 // mode so its JS client takes over synchronized playback.
149 stopPositionTimer();
150 tvx.InteractionPlugin.executeAction("link:" + BRIDGE + msg.url);
151 } else if (msg.type === "seek" && msg.position != null && typeof tvx.InteractionPlugin.executeAction === "function") {
152 var seekSecs = Math.round(msg.position);
153 startPositionTimer(seekSecs);
154 tvx.InteractionPlugin.executeAction("player:seek:" + seekSecs);
155 } else if (msg.type === "goto_index" && msg.index != null && typeof tvx.InteractionPlugin.executeAction === "function") {
156 startPositionTimer(0);
157 tvx.InteractionPlugin.executeAction("player:goto:index:" + msg.index);
158 } else if (msg.type === "play_update" && typeof tvx.InteractionPlugin.executeAction === "function") {
159 // Update metadata of the currently playing item without restarting audio.
160 // 'player:set-data' or 'player:update' is not supported on all MSX clients.
161 // For now, we just log it. Real metadata updates happen on track change via 'play' event.
162 /*
163 var playerLabelUpdate = msg.title || "Music Assistant";
164 var labelPartsUpdate = [];
165 if (msg.artist) labelPartsUpdate.push(msg.artist);
166 var durUpdate = formatDuration(msg.duration);
167 if (durUpdate) labelPartsUpdate.push(durUpdate);
168 var labelUpdate = labelPartsUpdate.join(" · ") || "";
169 var updateData = { playerLabel: playerLabelUpdate };
170 if (labelUpdate) updateData.label = labelUpdate;
171 if (msg.image_url) updateData.background = msg.image_url;
172 if (msg.duration != null && msg.duration > 0) updateData.duration = msg.duration;
173 if (msg.next_action) updateData.nextAction = resolveAction(msg.next_action);
174 if (msg.prev_action) updateData.prevAction = resolveAction(msg.prev_action);
175 tvx.InteractionPlugin.executeAction("player:set-data", updateData);
176 */
177 if (typeof console !== "undefined" && console.log) {
178 console.log("MSX play_update received", msg);
179 }
180 }
181 } catch (e) {
182 if (typeof console !== "undefined" && console.warn) {
183 console.warn("MSX push message error", e);
184 }
185 }
186 };
187 ws.onclose = function(event) {
188 if (wsConnection !== thisConnection) return; // stale connection
189 stopPositionTimer();
190 wsConnection = null;
191 if (event.code !== 1000 && event.code !== 1001) {
192 var jitter = Math.random() * 1000;
193 setTimeout(function() { connectPushWebSocket(deviceId); }, wsRetryDelay + jitter);
194 wsRetryDelay = Math.min(wsRetryDelay * 2, 10000);
195 }
196 };
197 return ws;
198 }
199
200 function MAHandler() {}
201
202 function getCurrentPosition() {
203 return positionStartValue + (Date.now() / 1000 - positionStartTime);
204 }
205
206 function sendWsMessage(msg) {
207 if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
208 try {
209 wsConnection.send(JSON.stringify(msg));
210 } catch (e) {
211 if (typeof console !== "undefined" && console.warn) {
212 console.warn("WebSocket send failed", e);
213 }
214 }
215 }
216 }
217
218 function onMediaPause() {
219 if (pausedByServer) {
220 pausedByServer = false;
221 return;
222 }
223 // User-initiated pause (TV remote) â notify MA
224 var pos = monitoredElement ? monitoredElement.currentTime : getCurrentPosition();
225 pausedAtPosition = pos;
226 stopPositionTimer();
227 sendWsMessage({ type: "pause", position: pos });
228 }
229
230 function onMediaPlay() {
231 if (resumedByServer) {
232 resumedByServer = false;
233 return;
234 }
235 // User-initiated resume (TV remote) â notify MA
236 startPositionTimer(pausedAtPosition);
237 sendWsMessage({ type: "resume" });
238 }
239
240 function attachMediaListeners(el) {
241 if (monitoredElement === el) return;
242 detachMediaListeners();
243 monitoredElement = el;
244 el.addEventListener("pause", onMediaPause);
245 el.addEventListener("play", onMediaPlay);
246 }
247
248 function detachMediaListeners() {
249 if (monitoredElement) {
250 monitoredElement.removeEventListener("pause", onMediaPause);
251 monitoredElement.removeEventListener("play", onMediaPlay);
252 monitoredElement = null;
253 }
254 }
255
256 function scanForMediaElements() {
257 var elements = document.querySelectorAll("audio, video");
258 for (var i = 0; i < elements.length; i++) {
259 if (elements[i].src || elements[i].currentSrc) {
260 attachMediaListeners(elements[i]);
261 return;
262 }
263 }
264 }
265
266 function startMediaObserver() {
267 if (mediaObserver) return;
268 scanForMediaElements();
269 if (typeof MutationObserver !== "undefined") {
270 mediaObserver = new MutationObserver(function() {
271 scanForMediaElements();
272 });
273 mediaObserver.observe(document.body, { childList: true, subtree: true });
274 }
275 }
276
277 // Generate a random UUID for device identification
278 function generateUUID() {
279 // Try tvx.Tools.getUniqueId() first
280 if (typeof tvx !== "undefined" && tvx.Tools && typeof tvx.Tools.getUniqueId === "function") {
281 try {
282 var tvxId = tvx.Tools.getUniqueId();
283 if (tvxId) return tvxId;
284 } catch (e) {}
285 }
286 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
287 return crypto.randomUUID();
288 }
289 if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
290 return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function(c) {
291 return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
292 });
293 }
294 // Crypto API completely unavailable â generate a timestamp-based fallback.
295 // This is non-secure but prevents cross-TV player ID collisions better than Math.random().
296 return 'msx-' + Date.now().toString(36) + '-' + Math.floor(performance.now() * 1000).toString(36);
297 }
298
299 // Try multiple methods to get a device ID from MSX/TVX
300 function getMsxDeviceId() {
301 // Method 1: tvx.VideoPlugin.getDeviceId() - synchronous (found in debug!)
302 if (typeof tvx !== "undefined" && tvx.VideoPlugin && typeof tvx.VideoPlugin.getDeviceId === "function") {
303 try {
304 var videoDeviceId = tvx.VideoPlugin.getDeviceId();
305 if (videoDeviceId) {
306 console.log("MSX device_id from VideoPlugin.getDeviceId:", videoDeviceId);
307 return videoDeviceId;
308 }
309 } catch (e) {}
310 }
311
312 // Method 2: tvx.Tools.getHostDeviceId() - synchronous
313 if (typeof tvx !== "undefined" && tvx.Tools && typeof tvx.Tools.getHostDeviceId === "function") {
314 try {
315 var hostId = tvx.Tools.getHostDeviceId();
316 if (hostId) {
317 console.log("MSX device_id from getHostDeviceId:", hostId);
318 return hostId;
319 }
320 } catch (e) {}
321 }
322
323 // Method 3: tvx.Settings.get("device")
324 if (typeof tvx !== "undefined" && tvx.Settings && typeof tvx.Settings.get === "function") {
325 try {
326 var deviceInfo = tvx.Settings.get("device");
327 if (deviceInfo && deviceInfo.id) {
328 console.log("MSX device_id from Settings.device:", deviceInfo.id);
329 return deviceInfo.id;
330 }
331 } catch (e) {}
332 }
333
334 // Method 4: tvx.Tools.getUniqueId()
335 if (typeof tvx !== "undefined" && tvx.Tools && typeof tvx.Tools.getUniqueId === "function") {
336 try {
337 var uniqueId = tvx.Tools.getUniqueId();
338 if (uniqueId) {
339 console.log("MSX device_id from getUniqueId:", uniqueId);
340 return uniqueId;
341 }
342 } catch (e) {}
343 }
344
345 return null;
346 }
347
348 // Get or create a persistent device ID stored in localStorage
349 function getOrCreateLocalDeviceId() {
350 var STORAGE_KEY = "ma_msx_device_id";
351 var storedId = null;
352 try {
353 storedId = localStorage.getItem(STORAGE_KEY);
354 } catch (e) {}
355 if (storedId) {
356 console.log("MSX device_id from localStorage:", storedId);
357 return storedId;
358 }
359 var newId = "msx_" + generateUUID();
360 try {
361 localStorage.setItem(STORAGE_KEY, newId);
362 console.log("MSX device_id generated and saved:", newId);
363 } catch (e) {
364 console.log("MSX device_id generated (no localStorage):", newId);
365 }
366 return newId;
367 }
368
369 // Debug: collect all available TVX info and return as object
370 function collectTvxDebugInfo() {
371 var info = { tvx_available: typeof tvx !== "undefined" };
372 if (typeof tvx === "undefined") return info;
373
374 // tvx.Tools methods
375 if (tvx.Tools) {
376 info.tools_methods = Object.keys(tvx.Tools);
377 try { info.getHostDeviceId = tvx.Tools.getHostDeviceId(); } catch(e) { info.getHostDeviceId_error = String(e); }
378 try { info.getUniqueId = tvx.Tools.getUniqueId(); } catch(e) { info.getUniqueId_error = String(e); }
379 try { info.getHostInfo = tvx.Tools.getHostInfo(); } catch(e) { info.getHostInfo_error = String(e); }
380 try { info.getPlatformInfo = tvx.Tools.getPlatformInfo(); } catch(e) { info.getPlatformInfo_error = String(e); }
381 }
382
383 // tvx.Settings
384 if (tvx.Settings && typeof tvx.Settings.get === "function") {
385 try { info.settings_device = tvx.Settings.get("device"); } catch(e) { info.settings_device_error = String(e); }
386 try { info.settings_platform = tvx.Settings.get("platform"); } catch(e) { info.settings_platform_error = String(e); }
387 try { info.settings_info = tvx.Settings.get("info"); } catch(e) { info.settings_info_error = String(e); }
388 try { info.settings_id = tvx.Settings.get("id"); } catch(e) { info.settings_id_error = String(e); }
389 }
390
391 // tvx.VideoPlugin
392 if (tvx.VideoPlugin) {
393 info.videoPlugin_methods = Object.keys(tvx.VideoPlugin);
394 try { info.videoPlugin_getDeviceId = tvx.VideoPlugin.getDeviceId(); } catch(e) { info.videoPlugin_getDeviceId_error = String(e); }
395 }
396
397 // tvx.InteractionPlugin
398 if (tvx.InteractionPlugin && typeof tvx.InteractionPlugin.getInfo === "function") {
399 try { info.interactionPlugin_info = tvx.InteractionPlugin.getInfo(); } catch(e) {}
400 }
401
402 // User agent
403 info.userAgent = navigator.userAgent;
404
405 return info;
406 }
407
408 // Send debug info to server via WebSocket
409 function sendDebugInfoToServer(ws) {
410 if (!ws || ws.readyState !== WebSocket.OPEN) return;
411 try {
412 var debugInfo = collectTvxDebugInfo();
413 ws.send(JSON.stringify({ type: "debug_info", data: debugInfo }));
414 } catch(e) {}
415 }
416
417 MAHandler.prototype.handleRequest = function(dataId, data, callback) {
418 if (dataId === "init") {
419 var respond = function(deviceId) {
420 // Priority: MSX provided ID > sync methods > localStorage fallback
421 var finalDeviceId = deviceId || getMsxDeviceId() || getOrCreateLocalDeviceId();
422 console.log("MSX Bridge final device_id:", finalDeviceId);
423
424 callback({
425 name: "Music Assistant",
426 version: "1.0.0",
427 headline: "Music Assistant",
428 menu: buildMenu(finalDeviceId)
429 });
430
431 var ws = connectPushWebSocket(finalDeviceId);
432 // Send debug info after connection opens
433 if (ws) {
434 var origOnOpen = ws.onopen;
435 ws.onopen = function() {
436 if (origOnOpen) origOnOpen.apply(this, arguments);
437 setTimeout(function() { sendDebugInfoToServer(ws); }, 500);
438 };
439 }
440 startMediaObserver();
441 };
442 // Try async method first (tvx.VideoPlugin.requestDeviceId)
443 if (typeof tvx !== "undefined" && tvx.VideoPlugin && typeof tvx.VideoPlugin.requestDeviceId === "function") {
444 var responded = false;
445 var deviceIdTimeout = setTimeout(function() {
446 if (!responded) {
447 responded = true;
448 respond(null);
449 }
450 }, 1500); // 1.5s timeout for async method
451 tvx.VideoPlugin.requestDeviceId(function(data) {
452 if (!responded) {
453 responded = true;
454 clearTimeout(deviceIdTimeout);
455 var id = (data && data.deviceId) ? data.deviceId : null;
456 if (id) console.log("MSX device_id from VideoPlugin.requestDeviceId:", id);
457 respond(id);
458 }
459 });
460 } else {
461 respond(null);
462 }
463 }
464 };
465
466 tvx.PluginTools.onReady(function() {
467 tvx.InteractionPlugin.setupHandler(new MAHandler());
468 tvx.InteractionPlugin.init();
469 });
470})();
471</script>
472</body>
473</html>
474