update readme
Some checks failed
Build Plugin / build (push) Successful in 2m5s
Release Plugin / build-and-release (push) Failing after 8s

improve scrubbing
improve syncing gui
This commit is contained in:
Duncan Tourolle 2025-12-14 16:16:33 +01:00
parent a8e0dcaf37
commit c967b062a2
11 changed files with 383 additions and 561 deletions

View File

@ -3,6 +3,64 @@
<head>
<meta charset="utf-8">
<title>JellyLMS</title>
<style>
.sync-group {
border: 2px solid #00a4dc;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
background: rgba(0, 164, 220, 0.05);
}
.sync-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sync-group-players {
color: #ccc;
}
.player-sync-row {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #333;
}
.player-sync-row:last-child {
border-bottom: none;
}
.player-sync-checkbox {
margin-right: 12px;
}
.player-sync-name {
flex: 1;
font-weight: 500;
}
.player-sync-status {
display: flex;
align-items: center;
gap: 6px;
color: #888;
font-size: 0.9em;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on { background: #52b54b; }
.status-dot.standby { background: #f9a825; }
.status-dot.off { background: #f44336; }
.sync-actions {
margin-top: 15px;
display: flex;
gap: 10px;
align-items: center;
}
#syncStatus {
margin-left: 10px;
}
</style>
</head>
<body>
<div id="JellyLmsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
@ -73,18 +131,6 @@
</label>
<div class="fieldDescription checkboxFieldDescription">Automatically sync players when playing to multiple devices</div>
</div>
</div>
<div class="verticalSection">
<h3>LMS Players</h3>
<div id="playersList">
<p>Click "Refresh Players" to discover LMS players.</p>
</div>
<div>
<button is="emby-button" type="button" id="btnRefreshPlayers" class="raised button-alt block emby-button">
<span>Refresh Players</span>
</button>
</div>
<div class="inputContainer" style="margin-top: 15px;">
<label class="inputLabel inputLabelUnfocused" for="DefaultPlayerMac">Default Player</label>
@ -96,15 +142,25 @@
</div>
<div class="verticalSection">
<h3>Player Sync Management</h3>
<p>Manage synchronized playback groups for multi-room audio.</p>
<div>
<a is="emby-button" href="configurationpage?name=JellyLMS%20Sync" class="raised button-alt block emby-button" style="display: inline-block; text-decoration: none;">
<span>Open Sync Manager</span>
</a>
<h3>Player Sync</h3>
<p class="fieldDescription">Select players to sync together for multi-room audio. Synced players play in perfect sync.</p>
<div id="currentSyncGroups">
<!-- Existing sync groups shown here -->
</div>
<div class="fieldDescription" style="margin-top: 10px;">
Users can access this page directly at: <code>/web/configurationpage?name=JellyLMS%20Sync</code>
<div id="playerSyncList">
<p>Loading players...</p>
</div>
<div class="sync-actions">
<button is="emby-button" type="button" id="btnSyncSelected" class="raised button-submit emby-button" disabled>
<span>Sync Selected</span>
</button>
<button is="emby-button" type="button" id="btnRefreshPlayers" class="raised button-alt emby-button">
<span>Refresh</span>
</button>
<span id="syncStatus"></span>
</div>
</div>
@ -118,45 +174,34 @@
</div>
<script type="text/javascript">
var JellyLmsConfig = {
pluginUniqueId: 'a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d'
pluginUniqueId: 'a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
players: [],
syncGroups: []
};
function getStatusClass(player) {
if (!player.IsConnected) return 'off';
return player.IsPoweredOn ? 'on' : 'standby';
}
function getStatusText(player) {
if (!player.IsConnected) return 'Disconnected';
return player.IsPoweredOn ? 'On' : 'Standby';
}
function loadPlayers() {
var playersList = document.querySelector('#playersList');
var defaultSelect = document.querySelector('#DefaultPlayerMac');
var currentDefault = defaultSelect.value;
playersList.innerHTML = '<p>Loading players...</p>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/Players', { refresh: true }),
type: 'GET',
dataType: 'json'
}).then(function(players) {
JellyLmsConfig.players = players || [];
defaultSelect.innerHTML = '<option value="">None</option>';
if (!players || players.length === 0) {
playersList.innerHTML = '<p>No players found. Make sure LMS is running and has connected players.</p>';
return;
}
var html = '<table class="tblGenres detailTable" style="width: 100%;">';
html += '<thead><tr><th>Name</th><th>Model</th><th>Status</th><th>Volume</th><th>Synced</th></tr></thead>';
html += '<tbody>';
players.forEach(function(player) {
var status = player.IsConnected ? (player.IsPoweredOn ? 'On' : 'Standby') : 'Disconnected';
var statusColor = player.IsConnected ? (player.IsPoweredOn ? 'green' : 'orange') : 'red';
var syncStatus = player.IsSynced ? 'Yes' : 'No';
html += '<tr>';
html += '<td>' + player.Name + '</td>';
html += '<td>' + player.Model + '</td>';
html += '<td style="color: ' + statusColor + ';">' + status + '</td>';
html += '<td>' + player.Volume + '%</td>';
html += '<td>' + syncStatus + '</td>';
html += '</tr>';
var option = document.createElement('option');
option.value = player.MacAddress;
option.text = player.Name;
@ -166,14 +211,179 @@
defaultSelect.appendChild(option);
});
html += '</tbody></table>';
playersList.innerHTML = html;
renderPlayerList();
}).catch(function(err) {
playersList.innerHTML = '<p style="color: red;">Error loading players. Check LMS connection.</p>';
document.querySelector('#playerSyncList').innerHTML = '<p style="color: red;">Error loading players. Check LMS connection.</p>';
console.error('Error loading players:', err);
});
}
function loadSyncGroups() {
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'GET',
dataType: 'json'
}).then(function(groups) {
JellyLmsConfig.syncGroups = groups || [];
renderSyncGroups();
renderPlayerList();
}).catch(function(err) {
console.error('Error loading sync groups:', err);
});
}
function renderSyncGroups() {
var container = document.querySelector('#currentSyncGroups');
var groups = JellyLmsConfig.syncGroups;
if (!groups || groups.length === 0) {
container.innerHTML = '';
return;
}
var html = '';
groups.forEach(function(group) {
var playerNames = [];
var masterPlayer = JellyLmsConfig.players.find(function(p) { return p.MacAddress === group.MasterMac; });
if (masterPlayer) playerNames.push(masterPlayer.Name);
group.SlaveMacs.forEach(function(mac) {
var player = JellyLmsConfig.players.find(function(p) { return p.MacAddress === mac; });
if (player) playerNames.push(player.Name);
});
html += '<div class="sync-group">';
html += '<div class="sync-group-header">';
html += '<span class="sync-group-players">' + playerNames.join(' + ') + '</span>';
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnUnsyncGroup" data-master="' + group.MasterMac + '">';
html += '<span>Unsync</span>';
html += '</button>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
container.querySelectorAll('.btnUnsyncGroup').forEach(function(btn) {
btn.addEventListener('click', function() {
unsyncGroup(this.getAttribute('data-master'));
});
});
}
function renderPlayerList() {
var container = document.querySelector('#playerSyncList');
var players = JellyLmsConfig.players;
if (!players || players.length === 0) {
container.innerHTML = '<p>No players found. Make sure LMS is running.</p>';
updateSyncButton();
return;
}
// Get MACs of already synced players
var syncedMacs = new Set();
JellyLmsConfig.syncGroups.forEach(function(group) {
syncedMacs.add(group.MasterMac);
group.SlaveMacs.forEach(function(mac) { syncedMacs.add(mac); });
});
// Only show unsynced players
var unsyncedPlayers = players.filter(function(p) {
return !syncedMacs.has(p.MacAddress);
});
if (unsyncedPlayers.length === 0) {
container.innerHTML = '<p>All players are synced.</p>';
updateSyncButton();
return;
}
var html = '';
unsyncedPlayers.forEach(function(player) {
var statusClass = getStatusClass(player);
html += '<div class="player-sync-row">';
html += '<input type="checkbox" class="player-sync-checkbox" data-mac="' + player.MacAddress + '" id="sync-' + player.MacAddress + '">';
html += '<label class="player-sync-name" for="sync-' + player.MacAddress + '">' + player.Name + '</label>';
html += '<div class="player-sync-status">';
html += '<span class="status-dot ' + statusClass + '"></span>';
html += '<span>' + getStatusText(player) + '</span>';
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
// Add change listeners
container.querySelectorAll('.player-sync-checkbox').forEach(function(cb) {
cb.addEventListener('change', updateSyncButton);
});
updateSyncButton();
}
function getSelectedMacs() {
var checkboxes = document.querySelectorAll('.player-sync-checkbox:checked');
var macs = [];
checkboxes.forEach(function(cb) {
macs.push(cb.getAttribute('data-mac'));
});
return macs;
}
function updateSyncButton() {
var btn = document.querySelector('#btnSyncSelected');
var selected = getSelectedMacs();
btn.disabled = selected.length < 2;
}
function syncSelected() {
var macs = getSelectedMacs();
if (macs.length < 2) return;
var statusDiv = document.querySelector('#syncStatus');
statusDiv.innerHTML = '<span style="color: orange;">Syncing...</span>';
// First MAC becomes master (arbitrary, user doesn't need to know)
var masterMac = macs[0];
var slaveMacs = macs.slice(1);
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
MasterMac: masterMac,
SlaveMacs: slaveMacs
})
}).then(function() {
statusDiv.innerHTML = '<span style="color: green;">Synced!</span>';
setTimeout(function() {
statusDiv.innerHTML = '';
loadSyncGroups();
}, 1500);
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Failed to sync.</span>';
console.error('Error syncing:', err);
});
}
function unsyncGroup(masterMac) {
var statusDiv = document.querySelector('#syncStatus');
statusDiv.innerHTML = '<span style="color: orange;">Unsyncing...</span>';
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups/' + encodeURIComponent(masterMac)),
type: 'DELETE'
}).then(function() {
statusDiv.innerHTML = '';
loadSyncGroups();
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Failed to unsync.</span>';
console.error('Error unsyncing:', err);
});
}
function testConnection() {
var statusDiv = document.querySelector('#connectionStatus');
statusDiv.innerHTML = '<span style="color: orange;">Testing connection...</span>';
@ -186,6 +396,7 @@
if (result.IsConnected) {
statusDiv.innerHTML = '<span style="color: green;">Connected! Found ' + result.PlayerCount + ' player(s).</span>';
loadPlayers();
loadSyncGroups();
} else {
statusDiv.innerHTML = '<span style="color: red;">Connection failed: ' + (result.LastError || 'Unknown error') + '</span>';
}
@ -208,6 +419,9 @@
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
Dashboard.hideLoadingMsg();
loadPlayers();
loadSyncGroups();
});
});
@ -226,6 +440,12 @@
document.querySelector('#btnRefreshPlayers')
.addEventListener('click', function() {
loadPlayers();
loadSyncGroups();
});
document.querySelector('#btnSyncSelected')
.addEventListener('click', function() {
syncSelected();
});
document.querySelector('#JellyLmsConfigForm')

View File

@ -1,490 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>LMS Player Sync</title>
<style>
.sync-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.player-card {
border: 1px solid #444;
border-radius: 8px;
padding: 15px;
min-width: 200px;
background: #1a1a1a;
}
.player-card.synced {
border-color: #00a4dc;
}
.player-card.master {
border-color: #52b54b;
border-width: 2px;
}
.player-name {
font-weight: bold;
font-size: 1.1em;
margin-bottom: 8px;
}
.player-info {
color: #888;
font-size: 0.9em;
margin-bottom: 10px;
}
.player-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.on { background: #52b54b; }
.status-dot.standby { background: #f9a825; }
.status-dot.off { background: #f44336; }
.sync-group {
border: 2px dashed #00a4dc;
border-radius: 12px;
padding: 15px;
margin-bottom: 20px;
background: rgba(0, 164, 220, 0.05);
}
.sync-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.sync-group-title {
font-weight: bold;
color: #00a4dc;
}
.sync-group-players {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.sync-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75em;
margin-left: 8px;
}
.sync-badge.master {
background: #52b54b;
color: white;
}
.sync-badge.slave {
background: #00a4dc;
color: white;
}
.unsynced-section {
margin-top: 30px;
}
.create-sync-section {
margin-top: 30px;
padding: 20px;
border: 1px solid #444;
border-radius: 8px;
background: #1a1a1a;
}
.player-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin: 15px 0;
}
.player-checkbox-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
}
.player-checkbox-item:hover {
border-color: #00a4dc;
}
.player-checkbox-item.selected {
border-color: #52b54b;
background: rgba(82, 181, 75, 0.1);
}
.player-checkbox-item.master-selected {
border-color: #52b54b;
border-width: 2px;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
.volume-slider {
width: 100%;
margin: 5px 0;
}
.help-text {
color: #888;
font-size: 0.9em;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div id="JellyLmsSyncPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h2>LMS Player Sync Management</h2>
<p>Manage synchronized playback groups for your LMS players. Synced players will play audio in perfect sync.</p>
<div>
<button is="emby-button" type="button" id="btnRefresh" class="raised button-alt emby-button">
<span>Refresh</span>
</button>
</div>
<div id="syncGroupsSection" style="margin-top: 20px;">
<h3>Current Sync Groups</h3>
<div id="syncGroupsList">
<p>Loading sync groups...</p>
</div>
</div>
<div id="unsyncedPlayersSection" class="unsynced-section">
<h3>Available Players</h3>
<div id="unsyncedPlayersList">
<p>Loading players...</p>
</div>
</div>
<div class="create-sync-section">
<h3>Create Sync Group</h3>
<p class="help-text">Select players to sync together. The first selected player will be the master (controls playback).</p>
<div id="playerSelectionList" class="player-checkbox-list">
<!-- Players will be populated here -->
</div>
<div class="action-buttons">
<button is="emby-button" type="button" id="btnCreateSync" class="raised button-submit emby-button" disabled>
<span>Create Sync Group</span>
</button>
<button is="emby-button" type="button" id="btnClearSelection" class="raised button-alt emby-button">
<span>Clear Selection</span>
</button>
</div>
<div id="syncStatus" style="margin-top: 10px;"></div>
</div>
</div>
</div>
<script type="text/javascript">
var JellyLmsSync = {
pluginUniqueId: 'a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
players: [],
syncGroups: [],
selectedPlayers: []
};
function getStatusClass(player) {
if (!player.IsConnected) return 'off';
return player.IsPoweredOn ? 'on' : 'standby';
}
function getStatusText(player) {
if (!player.IsConnected) return 'Disconnected';
return player.IsPoweredOn ? 'On' : 'Standby';
}
function loadData() {
Promise.all([
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/Players', { refresh: true }),
type: 'GET',
dataType: 'json'
}),
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'GET',
dataType: 'json'
})
]).then(function(results) {
JellyLmsSync.players = results[0] || [];
JellyLmsSync.syncGroups = results[1] || [];
renderSyncGroups();
renderUnsyncedPlayers();
renderPlayerSelection();
}).catch(function(err) {
console.error('Error loading data:', err);
document.querySelector('#syncGroupsList').innerHTML = '<p style="color: red;">Error loading data. Check LMS connection.</p>';
});
}
function renderSyncGroups() {
var container = document.querySelector('#syncGroupsList');
var groups = JellyLmsSync.syncGroups;
if (!groups || groups.length === 0) {
container.innerHTML = '<p>No sync groups configured. Create one below!</p>';
return;
}
var html = '';
groups.forEach(function(group) {
html += '<div class="sync-group">';
html += '<div class="sync-group-header">';
html += '<span class="sync-group-title">Sync Group (' + group.PlayerCount + ' players)</span>';
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnDissolveGroup" data-master="' + group.MasterMac + '">';
html += '<span>Dissolve Group</span>';
html += '</button>';
html += '</div>';
html += '<div class="sync-group-players">';
// Master player
var masterPlayer = JellyLmsSync.players.find(function(p) { return p.MacAddress === group.MasterMac; });
if (masterPlayer) {
html += renderPlayerCard(masterPlayer, true, false);
}
// Slave players
group.SlaveMacs.forEach(function(slaveMac, index) {
var slavePlayer = JellyLmsSync.players.find(function(p) { return p.MacAddress === slaveMac; });
if (slavePlayer) {
html += renderPlayerCard(slavePlayer, false, true, group.SlaveNames[index]);
}
});
html += '</div>';
html += '</div>';
});
container.innerHTML = html;
// Attach event listeners for dissolve buttons
container.querySelectorAll('.btnDissolveGroup').forEach(function(btn) {
btn.addEventListener('click', function() {
dissolveGroup(this.getAttribute('data-master'));
});
});
// Attach event listeners for unsync buttons
container.querySelectorAll('.btnUnsyncPlayer').forEach(function(btn) {
btn.addEventListener('click', function() {
unsyncPlayer(this.getAttribute('data-mac'));
});
});
}
function renderPlayerCard(player, isMaster, isSlave, slaveName) {
var statusClass = getStatusClass(player);
var cardClass = 'player-card';
if (isMaster) cardClass += ' master';
else if (isSlave) cardClass += ' synced';
var html = '<div class="' + cardClass + '">';
html += '<div class="player-name">' + player.Name;
if (isMaster) html += '<span class="sync-badge master">Master</span>';
else if (isSlave) html += '<span class="sync-badge slave">Synced</span>';
html += '</div>';
html += '<div class="player-info">' + player.Model + '</div>';
html += '<div class="player-status">';
html += '<span class="status-dot ' + statusClass + '"></span>';
html += '<span>' + getStatusText(player) + '</span>';
html += '<span style="margin-left: auto;">Vol: ' + player.Volume + '%</span>';
html += '</div>';
if (isSlave) {
html += '<button is="emby-button" type="button" class="raised button-alt emby-button btnUnsyncPlayer" data-mac="' + player.MacAddress + '" style="width: 100%; margin-top: 5px;">';
html += '<span>Unsync</span>';
html += '</button>';
}
html += '</div>';
return html;
}
function renderUnsyncedPlayers() {
var container = document.querySelector('#unsyncedPlayersList');
var syncedMacs = new Set();
JellyLmsSync.syncGroups.forEach(function(group) {
syncedMacs.add(group.MasterMac);
group.SlaveMacs.forEach(function(mac) { syncedMacs.add(mac); });
});
var unsyncedPlayers = JellyLmsSync.players.filter(function(p) {
return !syncedMacs.has(p.MacAddress);
});
if (unsyncedPlayers.length === 0) {
container.innerHTML = '<p>All players are in sync groups.</p>';
return;
}
var html = '<div class="sync-container">';
unsyncedPlayers.forEach(function(player) {
html += renderPlayerCard(player, false, false);
});
html += '</div>';
container.innerHTML = html;
}
function renderPlayerSelection() {
var container = document.querySelector('#playerSelectionList');
var players = JellyLmsSync.players;
if (!players || players.length === 0) {
container.innerHTML = '<p>No players available.</p>';
return;
}
// Only show unsynced players for selection
var syncedMacs = new Set();
JellyLmsSync.syncGroups.forEach(function(group) {
syncedMacs.add(group.MasterMac);
group.SlaveMacs.forEach(function(mac) { syncedMacs.add(mac); });
});
var availablePlayers = players.filter(function(p) {
return !syncedMacs.has(p.MacAddress);
});
if (availablePlayers.length < 2) {
container.innerHTML = '<p>Need at least 2 unsynced players to create a sync group.</p>';
return;
}
var html = '';
availablePlayers.forEach(function(player) {
var isSelected = JellyLmsSync.selectedPlayers.indexOf(player.MacAddress) !== -1;
var isMaster = JellyLmsSync.selectedPlayers.length > 0 && JellyLmsSync.selectedPlayers[0] === player.MacAddress;
var statusClass = getStatusClass(player);
var itemClass = 'player-checkbox-item';
if (isSelected) itemClass += ' selected';
if (isMaster) itemClass += ' master-selected';
html += '<div class="' + itemClass + '" data-mac="' + player.MacAddress + '">';
html += '<span class="status-dot ' + statusClass + '"></span>';
html += '<span>' + player.Name + '</span>';
if (isMaster) html += '<span class="sync-badge master">Master</span>';
else if (isSelected) html += '<span class="sync-badge slave">Slave</span>';
html += '</div>';
});
container.innerHTML = html;
// Attach click listeners
container.querySelectorAll('.player-checkbox-item').forEach(function(item) {
item.addEventListener('click', function() {
togglePlayerSelection(this.getAttribute('data-mac'));
});
});
updateCreateButton();
}
function togglePlayerSelection(mac) {
var index = JellyLmsSync.selectedPlayers.indexOf(mac);
if (index === -1) {
JellyLmsSync.selectedPlayers.push(mac);
} else {
JellyLmsSync.selectedPlayers.splice(index, 1);
}
renderPlayerSelection();
}
function updateCreateButton() {
var btn = document.querySelector('#btnCreateSync');
btn.disabled = JellyLmsSync.selectedPlayers.length < 2;
}
function createSyncGroup() {
if (JellyLmsSync.selectedPlayers.length < 2) return;
var statusDiv = document.querySelector('#syncStatus');
statusDiv.innerHTML = '<span style="color: orange;">Creating sync group...</span>';
var masterMac = JellyLmsSync.selectedPlayers[0];
var slaveMacs = JellyLmsSync.selectedPlayers.slice(1);
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
MasterMac: masterMac,
SlaveMacs: slaveMacs
})
}).then(function() {
statusDiv.innerHTML = '<span style="color: green;">Sync group created successfully!</span>';
JellyLmsSync.selectedPlayers = [];
setTimeout(function() {
statusDiv.innerHTML = '';
loadData();
}, 1500);
}).catch(function(err) {
statusDiv.innerHTML = '<span style="color: red;">Failed to create sync group.</span>';
console.error('Error creating sync group:', err);
});
}
function dissolveGroup(masterMac) {
if (!confirm('Dissolve this sync group? All players will be unsynced.')) return;
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups/' + encodeURIComponent(masterMac)),
type: 'DELETE'
}).then(function() {
loadData();
}).catch(function(err) {
alert('Failed to dissolve sync group.');
console.error('Error dissolving sync group:', err);
});
}
function unsyncPlayer(mac) {
ApiClient.ajax({
url: ApiClient.getUrl('JellyLms/SyncGroups/Players/' + encodeURIComponent(mac)),
type: 'DELETE'
}).then(function() {
loadData();
}).catch(function(err) {
alert('Failed to unsync player.');
console.error('Error unsyncing player:', err);
});
}
function clearSelection() {
JellyLmsSync.selectedPlayers = [];
renderPlayerSelection();
}
document.querySelector('#JellyLmsSyncPage')
.addEventListener('pageshow', function() {
loadData();
});
document.querySelector('#btnRefresh')
.addEventListener('click', function() {
loadData();
});
document.querySelector('#btnCreateSync')
.addEventListener('click', function() {
createSyncGroup();
});
document.querySelector('#btnClearSelection')
.addEventListener('click', function() {
clearSelection();
});
</script>
</div>
</body>
</html>

View File

@ -27,9 +27,7 @@
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Configuration\syncPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\syncPage.html" />
</ItemGroup>
</Project>

View File

@ -49,11 +49,6 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
},
new PluginPageInfo
{
Name = "JellyLMS Sync",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.syncPage.html", GetType().Namespace)
}
];
}

View File

@ -226,8 +226,10 @@ public class LmsApiClient : ILmsApiClient, IDisposable
{
try
{
_logger.LogInformation("LMS SeekAsync: player {Mac}, position {Seconds}s", playerMac, positionSeconds);
await SendCommandAsync<object>(playerMac, ["time", positionSeconds.ToString("F1", CultureInfo.InvariantCulture)])
.ConfigureAwait(false);
_logger.LogInformation("LMS SeekAsync: command sent successfully");
return true;
}
catch (Exception ex)

View File

@ -28,6 +28,7 @@ public class LmsSessionController : ISessionController, IDisposable
private BaseItem? _currentItem;
private Guid[] _playlist = [];
private int _playlistIndex;
private long _seekOffsetTicks; // Offset from transcoded stream start position
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class.
@ -88,10 +89,20 @@ public class LmsSessionController : ISessionController, IDisposable
CancellationToken cancellationToken)
{
_logger.LogInformation(
"LMS Session Controller received message {MessageType} for player {PlayerName} ({Mac})",
"LMS Session Controller received message {MessageType} for player {PlayerName} ({Mac}), data type: {DataType}",
name,
_player.Name,
_player.MacAddress);
_player.MacAddress,
data?.GetType().Name ?? "null");
// Log the data for debugging
if (data is PlaystateRequest psr)
{
_logger.LogInformation(
"PlaystateRequest: Command={Command}, SeekPositionTicks={Ticks}",
psr.Command,
psr.SeekPositionTicks);
}
try
{
@ -170,6 +181,7 @@ public class LmsSessionController : ISessionController, IDisposable
CurrentItemId = itemId;
IsPlaying = true;
IsPaused = false;
_seekOffsetTicks = 0; // Reset seek offset when starting a new track
// Look up the item for duration info
_currentItem = _libraryManager.GetItemById(itemId);
@ -249,17 +261,45 @@ public class LmsSessionController : ISessionController, IDisposable
return;
}
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
// LMS reports time relative to the current stream, but after seeking
// we're playing a transcoded stream that starts at the seek position.
// Add the seek offset to get the actual track position.
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks;
var isPaused = status.Mode == "pause";
// Update our local state from LMS
IsPaused = isPaused;
// Check if playback has stopped on LMS side
if (status.Mode == "stop")
// Check if playback has stopped on LMS side (track ended)
// Only advance if we're not paused - LMS can briefly report "stop" during transitions
if (status.Mode == "stop" && !IsPaused)
{
_logger.LogInformation("LMS playback stopped, reporting to Jellyfin");
await ReportPlaybackStoppedAsync().ConfigureAwait(false);
// Double-check by getting status again after a brief delay to avoid false positives
await Task.Delay(500).ConfigureAwait(false);
var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (confirmStatus?.Mode != "stop")
{
_logger.LogDebug("LMS mode changed from stop, ignoring");
return;
}
_logger.LogInformation("LMS playback stopped, checking if we should advance to next track");
// Check if there are more tracks in the playlist
if (_playlistIndex < _playlist.Length - 1)
{
_logger.LogInformation(
"Track ended, advancing to next track (index {Index}/{Total})",
_playlistIndex + 2,
_playlist.Length);
await PlayItemAtIndexAsync(_playlistIndex + 1).ConfigureAwait(false);
}
else
{
_logger.LogInformation("Playlist finished, reporting playback stopped");
await ReportPlaybackStoppedAsync().ConfigureAwait(false);
}
return;
}
@ -366,14 +406,34 @@ public class LmsSessionController : ISessionController, IDisposable
break;
case PlaystateCommand.Seek:
if (playstateRequest.SeekPositionTicks.HasValue)
_logger.LogInformation(
"Seek command received for player {PlayerName}, SeekPositionTicks: {Ticks}, CurrentItemId: {ItemId}, CurrentSeekOffset: {Offset}",
_player.Name,
playstateRequest.SeekPositionTicks,
CurrentItemId,
_seekOffsetTicks);
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
{
var positionSeconds = playstateRequest.SeekPositionTicks.Value / TimeSpan.TicksPerSecond;
var positionTicks = playstateRequest.SeekPositionTicks.Value;
// For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks
// Build a new URL with the seek position and restart playback
var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
_logger.LogInformation(
"Seeking player {PlayerName} to {Seconds} seconds",
_player.Name,
positionSeconds);
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
"Seeking by restarting stream at position {Seconds}s: {Url}",
positionTicks / TimeSpan.TicksPerSecond,
streamUrl);
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Track the seek offset so we report the correct position
// The transcoded stream starts at 0, but we need to report the actual track position
_seekOffsetTicks = positionTicks;
_logger.LogInformation("Set seek offset to {Ticks} ticks ({Seconds}s)", positionTicks, positionTicks / TimeSpan.TicksPerSecond);
}
else
{
_logger.LogWarning("Seek command received but SeekPositionTicks or CurrentItemId is null");
}
break;
@ -473,14 +533,29 @@ public class LmsSessionController : ISessionController, IDisposable
}
private string BuildStreamUrl(Guid itemId)
{
return BuildStreamUrlWithPosition(itemId, 0);
}
private string BuildStreamUrlWithPosition(Guid itemId, long startPositionTicks)
{
var config = Plugin.Instance?.Configuration;
var jellyfinUrl = config?.JellyfinServerUrl?.TrimEnd('/') ?? "http://localhost:8096";
var apiKey = config?.JellyfinApiKey ?? string.Empty;
// Build audio stream URL that LMS can fetch
// Use direct stream endpoint - simpler and more compatible
var url = $"{jellyfinUrl}/Audio/{itemId}/stream?static=true";
string url;
if (startPositionTicks > 0)
{
// For seeking, we need to use transcoding (static=true doesn't support startTimeTicks)
// Use MP3 transcoding with the start position
url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?audioCodec=mp3&audioBitRate=320000&startTimeTicks={startPositionTicks}";
}
else
{
// For normal playback from start, use static streaming (better quality, no transcoding)
url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?static=true";
}
if (!string.IsNullOrEmpty(apiKey))
{

View File

@ -37,6 +37,28 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
- **Playback Control**: Play, pause, stop, seek, and volume control forwarded to LMS
- **Stream Bridging**: Generates audio stream URLs from Jellyfin for LMS to consume
## Screenshots
### Cast to LMS Players
Select any LMS player directly from Jellyfin's "Play On" menu:
![Play On Menu](images/usage.png)
### Plugin Configuration
Configure LMS and Jellyfin server connections:
![Configuration Page](images/settings.png)
### Player Discovery
View discovered LMS players with their status and volume levels:
![Player List](images/settings%202.png)
### Sync Group Management
Create and manage synchronized playback groups for multi-room audio:
![Sync Manager](images/sync.png)
## Requirements
- Jellyfin Server 10.10.0 or later

BIN
images/settings 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
images/sync.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/usage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB