Duncan Tourolle 4d2f7df217
Some checks failed
Build Plugin / build (push) Successful in 49s
Release Plugin / build-and-release (push) Failing after 39s
new build system
inject controls in homepage
2026-06-13 23:35:46 +02:00

394 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>JellyLMS Remote</title>
<style>
:root {
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #101010;
color: #fff;
padding: 16px;
padding-bottom: 60px;
}
h1 {
font-size: 1.4em;
font-weight: 500;
margin: 8px 0 16px;
}
h2 {
font-size: 1.05em;
font-weight: 500;
color: #ccc;
margin: 24px 0 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card {
background: #1c1c1c;
border-radius: 10px;
padding: 14px;
margin-bottom: 10px;
}
.player-row {
display: flex;
align-items: center;
gap: 12px;
}
.player-info {
flex: 1;
min-width: 0;
}
.player-name {
font-weight: 500;
font-size: 1.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-status {
display: flex;
align-items: center;
gap: 6px;
color: #888;
font-size: 0.85em;
margin-top: 2px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.on { background: #52b54b; }
.status-dot.standby { background: #f9a825; }
.status-dot.off { background: #f44336; }
.power-btn {
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 1.2em;
background: #2a2a2a;
color: #aaa;
cursor: pointer;
flex-shrink: 0;
}
.power-btn.on {
background: #00a4dc;
color: #fff;
}
.volume-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.volume-row input[type="range"] {
flex: 1;
}
.volume-value {
width: 2.5em;
text-align: right;
color: #ccc;
font-size: 0.9em;
}
.sync-group-players {
color: #ccc;
flex: 1;
}
.sync-checkbox-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
button.action {
background: #00a4dc;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
font-size: 0.95em;
cursor: pointer;
}
button.action:disabled {
background: #333;
color: #777;
cursor: default;
}
button.action.alt {
background: #333;
color: #ccc;
}
.empty, .message {
color: #888;
padding: 8px 0;
}
.message.error { color: #f44336; }
.message a { color: #00a4dc; }
#refreshBtn {
position: fixed;
bottom: 16px;
right: 16px;
}
</style>
</head>
<body>
<h1>🔊 JellyLMS Remote</h1>
<div id="app">
<p class="message">Loading…</p>
</div>
<script>
var API_BASE = '/JellyLms';
var state = { players: [], syncGroups: [] };
function getAuthToken() {
try {
var creds = JSON.parse(localStorage.getItem('jellyfin_credentials'));
var server = creds && creds.Servers && creds.Servers[0];
return (server && server.AccessToken) || null;
} catch (e) {
return null;
}
}
function api(path, options) {
options = options || {};
var headers = options.headers || {};
var token = getAuthToken();
if (token) {
headers['X-Emby-Token'] = token;
}
if (options.body) {
headers['Content-Type'] = 'application/json';
}
return fetch(API_BASE + path, {
method: options.method || 'GET',
headers: headers,
body: options.body
}).then(function (resp) {
if (!resp.ok) {
var err = new Error('Request failed: ' + resp.status);
err.status = resp.status;
throw err;
}
if (resp.status === 204) {
return null;
}
var contentType = resp.headers.get('content-type') || '';
return contentType.indexOf('application/json') !== -1 ? resp.json() : null;
});
}
function renderMessage(text, isError) {
document.getElementById('app').innerHTML =
'<p class="message' + (isError ? ' error' : '') + '">' + text + '</p>';
}
function getStatusClass(player) {
if (!player.IsConnected) return 'off';
return player.IsPoweredOn ? 'on' : 'standby';
}
function getStatusText(player) {
if (!player.IsConnected) return 'Disconnected';
return player.IsPoweredOn ? 'Playing' : 'Standby';
}
function syncedMacs() {
var macs = new Set();
state.syncGroups.forEach(function (group) {
macs.add(group.MasterMac);
group.SlaveMacs.forEach(function (mac) { macs.add(mac); });
});
return macs;
}
function findPlayer(mac) {
return state.players.find(function (p) { return p.MacAddress === mac; });
}
function render() {
var app = document.getElementById('app');
var html = '';
html += '<h2>Players</h2>';
if (state.players.length === 0) {
html += '<p class="empty">No players found.</p>';
} else {
state.players.forEach(function (player) {
var mac = player.MacAddress;
html += '<div class="card">';
html += '<div class="player-row">';
html += '<div class="player-info">';
html += '<div class="player-name">' + player.Name + '</div>';
html += '<div class="player-status"><span class="status-dot ' + getStatusClass(player) + '"></span>' +
'<span>' + getStatusText(player) + '</span></div>';
html += '</div>';
html += '<button class="power-btn' + (player.IsPoweredOn ? ' on' : '') + '" data-action="power" data-mac="' + mac + '" data-on="' + player.IsPoweredOn + '" title="Power">⏻</button>';
html += '</div>';
html += '<div class="volume-row">';
html += '<span>🔈</span>';
html += '<input type="range" min="0" max="100" value="' + player.Volume + '" data-action="volume" data-mac="' + mac + '">';
html += '<span class="volume-value">' + player.Volume + '</span>';
html += '</div>';
html += '</div>';
});
}
html += '<h2>Multi-Room Sync</h2>';
if (state.syncGroups.length > 0) {
state.syncGroups.forEach(function (group) {
var names = [];
var master = findPlayer(group.MasterMac);
if (master) names.push(master.Name);
group.SlaveMacs.forEach(function (mac) {
var p = findPlayer(mac);
if (p) names.push(p.Name);
});
html += '<div class="card">';
html += '<div class="player-row">';
html += '<span class="sync-group-players">' + names.join(' + ') + '</span>';
html += '<button class="action alt" data-action="unsync" data-master="' + group.MasterMac + '">Unsync</button>';
html += '</div>';
html += '</div>';
});
}
var unsynced = state.players.filter(function (p) { return !syncedMacs().has(p.MacAddress); });
if (unsynced.length > 1) {
html += '<div class="card">';
html += '<p class="empty" style="margin-top:0;">Select players to sync together:</p>';
unsynced.forEach(function (player) {
html += '<label class="sync-checkbox-row">';
html += '<input type="checkbox" data-action="sync-select" data-mac="' + player.MacAddress + '">';
html += '<span>' + player.Name + '</span>';
html += '</label>';
});
html += '<div style="margin-top:10px;">';
html += '<button class="action" id="syncSelectedBtn" disabled>Sync Selected</button>';
html += '</div>';
html += '</div>';
} else if (state.syncGroups.length === 0) {
html += '<p class="empty">No players synced yet.</p>';
}
app.innerHTML = html;
attachHandlers();
}
function attachHandlers() {
document.querySelectorAll('[data-action="power"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var mac = btn.getAttribute('data-mac');
var isOn = btn.getAttribute('data-on') === 'true';
var endpoint = isOn ? '/Players/' + encodeURIComponent(mac) + '/PowerOff' : '/Players/' + encodeURIComponent(mac) + '/PowerOn';
btn.disabled = true;
api(endpoint, { method: 'POST' }).then(loadPlayers).catch(function () {
btn.disabled = false;
});
});
});
document.querySelectorAll('[data-action="volume"]').forEach(function (input) {
input.addEventListener('change', function () {
var mac = input.getAttribute('data-mac');
var volume = parseInt(input.value, 10);
input.nextElementSibling.textContent = volume;
api('/Players/' + encodeURIComponent(mac) + '/Volume', {
method: 'POST',
body: JSON.stringify({ Volume: volume })
}).catch(function () {});
});
input.addEventListener('input', function () {
input.nextElementSibling.textContent = input.value;
});
});
document.querySelectorAll('[data-action="unsync"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var masterMac = btn.getAttribute('data-master');
btn.disabled = true;
api('/SyncGroups/' + encodeURIComponent(masterMac), { method: 'DELETE' })
.then(loadAll)
.catch(function () { btn.disabled = false; });
});
});
var syncBtn = document.getElementById('syncSelectedBtn');
if (syncBtn) {
var checkboxes = document.querySelectorAll('[data-action="sync-select"]');
var updateSyncBtn = function () {
var checked = Array.from(checkboxes).filter(function (cb) { return cb.checked; });
syncBtn.disabled = checked.length < 2;
};
checkboxes.forEach(function (cb) { cb.addEventListener('change', updateSyncBtn); });
syncBtn.addEventListener('click', function () {
var macs = Array.from(checkboxes).filter(function (cb) { return cb.checked; })
.map(function (cb) { return cb.getAttribute('data-mac'); });
if (macs.length < 2) return;
syncBtn.disabled = true;
api('/SyncGroups', {
method: 'POST',
body: JSON.stringify({ MasterMac: macs[0], SlaveMacs: macs.slice(1) })
}).then(loadAll).catch(function () { syncBtn.disabled = false; });
});
}
}
function loadPlayers() {
return api('/Players?refresh=true').then(function (players) {
state.players = players || [];
render();
});
}
function loadSyncGroups() {
return api('/SyncGroups').then(function (groups) {
state.syncGroups = groups || [];
});
}
function loadAll() {
return Promise.all([loadPlayers(), loadSyncGroups()]).then(render);
}
function init() {
if (!getAuthToken()) {
renderMessage('Please <a href="/web/">log in to Jellyfin</a> first, then reload this page.', true);
return;
}
api('/RemoteControl/Access').then(function () {
loadAll().catch(function () {
renderMessage('Failed to load players. Check the JellyLMS plugin configuration.', true);
});
}).catch(function (err) {
if (err.status === 403) {
renderMessage('Your account does not have permission to use the multi-room remote. Ask an admin to grant "Allow remote control of other users".', true);
} else {
renderMessage('Could not reach JellyLMS. <a href="/web/">Return to Jellyfin</a>.', true);
}
});
}
init();
</script>
</body>
</html>