394 lines
14 KiB
HTML
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>
|