new build system
inject controls in homepage
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,49 @@
|
||||
(function () {
|
||||
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 addButton() {
|
||||
if (document.getElementById('jellylms-remote-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = document.createElement('a');
|
||||
btn.id = 'jellylms-remote-btn';
|
||||
btn.href = '/JellyLms/RemoteControl';
|
||||
btn.title = 'Multi-room Remote';
|
||||
btn.textContent = '🔊';
|
||||
btn.style.cssText = 'position:fixed;bottom:20px;right:20px;width:48px;height:48px;' +
|
||||
'border-radius:50%;background:#00a4dc;color:#fff;display:flex;' +
|
||||
'align-items:center;justify-content:center;font-size:22px;' +
|
||||
'text-decoration:none;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.5);';
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function init() {
|
||||
var token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/JellyLms/RemoteControl/Access', { headers: { 'X-Emby-Token': token } })
|
||||
.then(function (resp) {
|
||||
if (resp.ok) {
|
||||
addButton();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user