livedesk 0.1.224 → 0.1.226

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ export class Mode4AtlasSession {
10
10
  this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
11
11
  this.deviceIds = [];
12
12
  this.deviceSet = new Set();
13
+ this.inputDeviceIds = [];
14
+ this.inputDeviceSet = new Set();
13
15
  this.config = null;
14
16
  this.closed = false;
15
17
  this.worker = null;
@@ -65,7 +67,10 @@ export class Mode4AtlasSession {
65
67
  this.deviceIds = [...new Set((Array.isArray(options.deviceIds) ? options.deviceIds : [])
66
68
  .map(value => String(value || '').trim()).filter(Boolean))].slice(0, 100);
67
69
  this.deviceSet = new Set(this.deviceIds);
68
- this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.deviceSet.has(deviceId)));
70
+ this.inputDeviceIds = [...new Set((Array.isArray(options.inputDeviceIds) ? options.inputDeviceIds : this.deviceIds)
71
+ .map(value => String(value || '').trim()).filter(value => this.deviceSet.has(value)))].slice(0, 100);
72
+ this.inputDeviceSet = new Set(this.inputDeviceIds);
73
+ this.pendingFrames = new Map([...this.pendingFrames].filter(([deviceId]) => this.inputDeviceSet.has(deviceId)));
69
74
  this.config = {
70
75
  deviceIds: this.deviceIds,
71
76
  width: Number(options.width || 1920),
@@ -102,7 +107,7 @@ export class Mode4AtlasSession {
102
107
  const frame = frameEvent?.frame || {};
103
108
  const deviceId = String(frameEvent?.deviceId || frame.deviceId || '').trim();
104
109
  const mode = String(frame.frameMode || frame.mode || '').toLowerCase();
105
- if (!this.deviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
110
+ if (!this.inputDeviceSet.has(deviceId) || mode !== 'mode2-lzo' || !Buffer.isBuffer(frameEvent?.payload)) return;
106
111
  this.pendingFrames.set(deviceId, {
107
112
  type: 'frame',
108
113
  deviceId,
@@ -672,6 +672,7 @@ function createDeviceCounters(overrides = {}) {
672
672
  audioStreamsStopped: 0,
673
673
  audioFramesReceived: 0,
674
674
  audioFramesDropped: 0,
675
+ audioStatusReceived: 0,
675
676
  tasksQueued: 0,
676
677
  taskResultsReceived: 0,
677
678
  taskResultsFailed: 0,
@@ -958,6 +959,7 @@ function serializeDevice(device, options = {}) {
958
959
  byteLength: device.latestAudioFrame.byteLength
959
960
  }
960
961
  : null,
962
+ latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
961
963
  latestTask: device.latestTask ? { ...device.latestTask } : null,
962
964
  synthetic: device.synthetic === true,
963
965
  recentTasks: Array.isArray(device.recentTasks)
@@ -2456,6 +2458,7 @@ export function createRemoteHub(options = {}) {
2456
2458
  activeLiveStream: null,
2457
2459
  activeAudioStream: null,
2458
2460
  latestAudioFrame: null,
2461
+ latestAudioStatus: null,
2459
2462
  latestTask,
2460
2463
  recentTasks: latestTask ? [latestTask] : [],
2461
2464
  pendingTaskCommands: new Map(),
@@ -2621,6 +2624,7 @@ export function createRemoteHub(options = {}) {
2621
2624
  activeLiveStream: null,
2622
2625
  activeAudioStream: null,
2623
2626
  latestAudioFrame: null,
2627
+ latestAudioStatus: null,
2624
2628
  latestTask: null,
2625
2629
  recentTasks: [],
2626
2630
  pendingTaskCommands: new Map(),
@@ -3470,6 +3474,12 @@ export function createRemoteHub(options = {}) {
3470
3474
  transferProtocolVersion: transfer.transferProtocolVersion,
3471
3475
  handshake: transfer.handshake,
3472
3476
  fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : streamState.fps,
3477
+ requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
3478
+ effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
3479
+ captureAverageMs: readFrameStageMs(message, 'captureAverageMs'),
3480
+ captureP95Ms: readFrameStageMs(message, 'captureP95Ms'),
3481
+ slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
3482
+ captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
3473
3483
  capturedAt,
3474
3484
  receivedAt: device.lastSeenAt,
3475
3485
  byteLength,
@@ -3590,6 +3600,50 @@ export function createRemoteHub(options = {}) {
3590
3600
  return true;
3591
3601
  }
3592
3602
 
3603
+ function applyAudioStatus(device, message, framePayload, transport = 'binary') {
3604
+ if (!Buffer.isBuffer(framePayload) || framePayload.length < 1) {
3605
+ device.counters.audioFramesDropped += 1;
3606
+ return false;
3607
+ }
3608
+
3609
+ const streamId = safeString(message.streamId, 128) || device.activeAudioStream?.streamId || 'audio';
3610
+ const status = {
3611
+ streamId,
3612
+ commandId: safeString(message.commandId, 128),
3613
+ captureAlive: message.captureAlive === true,
3614
+ helperAlive: message.helperAlive === true,
3615
+ helperPid: Number(message.helperPid || 0) || 0,
3616
+ captureGeneration: Number(message.captureGeneration || 0) || 0,
3617
+ lastPcmAtEpochMs: Number(message.lastPcmAtEpochMs || 0) || 0,
3618
+ silentForMs: Math.max(0, Number(message.silentForMs || 0) || 0),
3619
+ restartCount: Math.max(0, Number(message.restartCount || 0) || 0),
3620
+ error: safeString(message.error, 500),
3621
+ statusAtEpochMs: Number(message.statusAtEpochMs || 0) || Date.now(),
3622
+ receivedAt: new Date().toISOString(),
3623
+ transport
3624
+ };
3625
+ device.latestAudioStatus = status;
3626
+ if (device.activeAudioStream && device.activeAudioStream.streamId === streamId) {
3627
+ device.activeAudioStream.lastStatusAt = status.receivedAt;
3628
+ device.activeAudioStream.captureAlive = status.captureAlive;
3629
+ device.activeAudioStream.helperAlive = status.helperAlive;
3630
+ device.activeAudioStream.captureGeneration = status.captureGeneration;
3631
+ device.activeAudioStream.lastPcmAtEpochMs = status.lastPcmAtEpochMs;
3632
+ device.activeAudioStream.silentForMs = status.silentForMs;
3633
+ device.activeAudioStream.restartCount = status.restartCount;
3634
+ device.activeAudioStream.audioError = status.error;
3635
+ }
3636
+ device.counters.audioStatusReceived += 1;
3637
+ emitAudio({
3638
+ deviceId: device.deviceId,
3639
+ audioStatus: true,
3640
+ frame: status,
3641
+ payload: framePayload,
3642
+ byteLength: framePayload.length
3643
+ });
3644
+ return true;
3645
+ }
3646
+
3593
3647
  function handleAgentBinaryFrame(socket, state, header, framePayload) {
3594
3648
  if (!state.authenticated || !state.device) {
3595
3649
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
@@ -3632,6 +3686,11 @@ export function createRemoteHub(options = {}) {
3632
3686
  return;
3633
3687
  }
3634
3688
 
3689
+ if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
3690
+ applyAudioStatus(device, header, framePayload, 'binary');
3691
+ return;
3692
+ }
3693
+
3635
3694
  emitRemoteEvent('RemoteAgentMessageIgnored', device, {
3636
3695
  messageType: safeString(header.type, 80),
3637
3696
  frameKind
@@ -3650,7 +3709,12 @@ export function createRemoteHub(options = {}) {
3650
3709
  && header.isKeyFrame !== true
3651
3710
  && chunkType !== 'key';
3652
3711
  });
3653
- const index = deltaIndex >= 0 ? deltaIndex : 0;
3712
+ const nonStatusIndex = state.binaryQueue.findIndex(item => {
3713
+ const header = item?.header || {};
3714
+ const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
3715
+ return frameKind !== 'audio-status' && header.type !== 'audio.status.binary';
3716
+ });
3717
+ const index = deltaIndex >= 0 ? deltaIndex : nonStatusIndex >= 0 ? nonStatusIndex : 0;
3654
3718
  state.binaryQueue.splice(index, 1);
3655
3719
  state.binaryQueueDrops = Number(state.binaryQueueDrops || 0) + 1;
3656
3720
  }
@@ -4751,16 +4815,27 @@ export function createRemoteHub(options = {}) {
4751
4815
  return `${role}-${hash}`;
4752
4816
  }
4753
4817
 
4818
+ function normalizeLivePlatform(value) {
4819
+ const normalized = safeString(value, 32).toLowerCase();
4820
+ if (['macos', 'darwin', 'mac', 'macosx'].includes(normalized)) return 'macos';
4821
+ if (['windows', 'win32', 'win32nt'].includes(normalized)) return 'windows';
4822
+ if (['linux', 'unix'].includes(normalized)) return 'linux';
4823
+ return normalized || 'unknown';
4824
+ }
4825
+
4754
4826
  function normalizeLiveStreamStartOptions(options = {}) {
4755
4827
  const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
4756
4828
  const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
4757
- const maxFps = streamPurpose === 'control' ? 60 : 30;
4758
- const fps = clampNumber(options.fps, 1, maxFps, 8);
4759
- const maxWidth = clampNumber(options.maxWidth, 320, 3840, 640);
4760
- const maxHeight = clampNumber(options.maxHeight, 180, 2160, 360);
4829
+ const platform = normalizeLivePlatform(options.platform);
4830
+ const macWallProfile = platform === 'macos' && streamPurpose !== 'control';
4831
+ const maxFps = streamPurpose === 'control' ? 60 : macWallProfile ? 8 : 30;
4832
+ const defaultFps = macWallProfile && options.fps === undefined ? 4 : 8;
4833
+ const maxWidth = clampNumber(options.maxWidth, 320, macWallProfile ? 432 : 3840, macWallProfile ? 432 : 640);
4834
+ const maxHeight = clampNumber(options.maxHeight, 180, macWallProfile ? 243 : 2160, macWallProfile ? 243 : 360);
4835
+ const fps = clampNumber(options.fps, 1, maxFps, defaultFps);
4761
4836
  const quality = clampNumber(options.quality, 20, 95, 45);
4762
4837
  const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
4763
- return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose };
4838
+ return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose, platform, macWallProfile };
4764
4839
  }
4765
4840
 
4766
4841
  function liveStreamStartStillPending(activeLiveStream) {
@@ -4821,7 +4896,7 @@ export function createRemoteHub(options = {}) {
4821
4896
  }
4822
4897
 
4823
4898
  const now = new Date().toISOString();
4824
- const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options });
4899
+ const normalized = normalizeLiveStreamStartOptions({ fps: 2, ...options, platform: device.platform });
4825
4900
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
4826
4901
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
4827
4902
  const activeLiveStream = getDeviceLiveStream(device, streamId);
@@ -4898,7 +4973,7 @@ export function createRemoteHub(options = {}) {
4898
4973
  }
4899
4974
 
4900
4975
  const now = new Date().toISOString();
4901
- const normalized = normalizeLiveStreamStartOptions(options);
4976
+ const normalized = normalizeLiveStreamStartOptions({ ...options, platform: device.platform });
4902
4977
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
4903
4978
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
4904
4979
  const activeLiveStream = getDeviceLiveStream(device, streamId);
@@ -5041,11 +5116,22 @@ export function createRemoteHub(options = {}) {
5041
5116
 
5042
5117
  function stopLiveStream(deviceId, options = {}) {
5043
5118
  const device = devices.get(String(deviceId || ''));
5119
+ const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
5120
+ const requestedStreamId = safeString(options.streamId, 128);
5121
+ const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
5122
+ const streamId = requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
5123
+ const streamState = getDeviceLiveStream(device, streamId);
5124
+ if (streamState && streamState.active !== true) {
5125
+ return {
5126
+ ok: true,
5127
+ commandId: safeString(options.commandId, 128),
5128
+ streamId,
5129
+ alreadyStopped: true
5130
+ };
5131
+ }
5044
5132
  if (device?.synthetic === true && device.connected) {
5045
- const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
5046
5133
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5047
5134
  device.counters.commandsSent += 1;
5048
- const streamState = getDeviceLiveStream(device, streamId);
5049
5135
  if (streamState) {
5050
5136
  streamState.active = false;
5051
5137
  streamState.stoppedAt = new Date().toISOString();
@@ -5064,7 +5150,6 @@ export function createRemoteHub(options = {}) {
5064
5150
  return { ok: false, error: 'device-not-connected' };
5065
5151
  }
5066
5152
 
5067
- const streamId = safeString(options.streamId, 128) || device.activeLiveStream?.streamId || '';
5068
5153
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5069
5154
  const result = sendCommand(deviceId, {
5070
5155
  command: 'stream.stop',
@@ -5079,7 +5164,6 @@ export function createRemoteHub(options = {}) {
5079
5164
  return result;
5080
5165
  }
5081
5166
 
5082
- const streamState = getDeviceLiveStream(device, streamId);
5083
5167
  if (streamState) {
5084
5168
  streamState.active = false;
5085
5169
  streamState.stoppedAt = new Date().toISOString();
package/hub/src/server.js CHANGED
@@ -101,11 +101,11 @@ function handleRemoteHubEvent(type, event) {
101
101
  if (!deviceId) {
102
102
  return;
103
103
  }
104
- for (const ws of atlasClients.keys()) {
105
- if (ws.readyState === 1 && ws.liveDeskAtlasDeviceIds?.has(deviceId)) {
106
- startMode4AtlasInput(ws, deviceId, 'device-connected');
107
- }
108
- }
104
+ for (const ws of atlasClients.keys()) {
105
+ if (ws.readyState === 1 && ws.liveDeskAtlasInputDeviceIds?.has(deviceId)) {
106
+ startMode4AtlasInput(ws, deviceId, 'device-connected');
107
+ }
108
+ }
109
109
  const clients = new Set([
110
110
  ...(frameClientsByDeviceId.get(deviceId) || []),
111
111
  ...frameWildcardClients
@@ -976,11 +976,12 @@ function safeWebSocketSend(ws, payload, options = undefined) {
976
976
  }
977
977
  }
978
978
 
979
- function unregisterFrameClient(ws) {
980
- if (!ws) {
981
- return;
982
- }
983
- const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
979
+ function unregisterFrameClient(ws) {
980
+ if (!ws) {
981
+ return;
982
+ }
983
+ stopFrameClientStreams(ws);
984
+ const ids = ws.liveDeskDeviceIds instanceof Set ? ws.liveDeskDeviceIds : new Set();
984
985
  for (const deviceId of ids) {
985
986
  const clients = frameClientsByDeviceId.get(deviceId);
986
987
  if (!clients) {
@@ -991,9 +992,49 @@ function unregisterFrameClient(ws) {
991
992
  frameClientsByDeviceId.delete(deviceId);
992
993
  }
993
994
  }
994
- frameWildcardClients.delete(ws);
995
- ws.liveDeskFrameSendLane?.queue?.splice?.(0);
996
- }
995
+ frameWildcardClients.delete(ws);
996
+ ws.liveDeskFrameSendLane?.queue?.splice?.(0);
997
+ }
998
+
999
+ function hasOtherFrameStreamOwner(ws, deviceId, streamId) {
1000
+ if (!streamId) {
1001
+ return false;
1002
+ }
1003
+ for (const candidate of frameClients) {
1004
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
1005
+ continue;
1006
+ }
1007
+ if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
1008
+ && candidate.liveDeskStreamIdsByDeviceId.get(deviceId) === streamId) {
1009
+ return true;
1010
+ }
1011
+ }
1012
+ return false;
1013
+ }
1014
+
1015
+ function stopFrameClientStreams(ws) {
1016
+ const streams = ws.liveDeskStreamIdsByDeviceId instanceof Map
1017
+ ? ws.liveDeskStreamIdsByDeviceId
1018
+ : null;
1019
+ if (!streams) {
1020
+ return;
1021
+ }
1022
+ const streamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
1023
+ for (const [deviceId, streamId] of streams.entries()) {
1024
+ if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId)) {
1025
+ continue;
1026
+ }
1027
+ const result = remoteHub.stopLiveStream(deviceId, {
1028
+ streamId,
1029
+ streamPurpose,
1030
+ reason: 'frame-client-closed'
1031
+ });
1032
+ if (result?.ok && !result.alreadyStopped) {
1033
+ console.log(`[LiveDesk Hub] stopped ${streamPurpose} stream after frame client closed device=${deviceId} stream=${streamId}`);
1034
+ }
1035
+ }
1036
+ streams.clear();
1037
+ }
997
1038
 
998
1039
  function registerFrameClient(ws) {
999
1040
  if (!ws) {
@@ -1322,9 +1363,15 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
1322
1363
  hardwareEncoder: frame.hardwareEncoder || '',
1323
1364
  platformProfile: frame.platformProfile || '',
1324
1365
  monitorIndex: Number.isFinite(Number(frame.monitorIndex)) ? Number(frame.monitorIndex) : 0,
1325
- monitorCount: Number.isFinite(Number(frame.monitorCount)) ? Number(frame.monitorCount) : 1,
1326
- fps: Number(frame.fps || 0) || 0,
1327
- capturedAt: frame.capturedAt || '',
1366
+ monitorCount: Number.isFinite(Number(frame.monitorCount)) ? Number(frame.monitorCount) : 1,
1367
+ fps: Number(frame.fps || 0) || 0,
1368
+ requestedFps: Number(frame.requestedFps || 0) || 0,
1369
+ effectiveFps: Number(frame.effectiveFps || 0) || 0,
1370
+ captureAverageMs: Number(frame.captureAverageMs || 0) || 0,
1371
+ captureP95Ms: Number(frame.captureP95Ms || 0) || 0,
1372
+ slowFrameCount: Number(frame.slowFrameCount || 0) || 0,
1373
+ captureHelperRestarts: Number(frame.captureHelperRestarts || 0) || 0,
1374
+ capturedAt: frame.capturedAt || '',
1328
1375
  receivedAt: frame.receivedAt || '',
1329
1376
  hubPacketAt,
1330
1377
  hubPacketEpochMs,
@@ -1354,24 +1401,38 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
1354
1401
  return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
1355
1402
  }
1356
1403
 
1357
- function buildRemoteAudioBinaryPacket(audioEvent) {
1358
- const payload = audioEvent?.payload;
1359
- if (!Buffer.isBuffer(payload) || payload.length === 0) {
1360
- return null;
1361
- }
1362
- const frame = audioEvent.frame || {};
1363
- const metadata = {
1364
- type: 'remote.audio.binary',
1365
- deviceId: audioEvent.deviceId || frame.deviceId || '',
1366
- frameSeq: Number(frame.frameSeq || 0) || 0,
1367
- streamId: frame.streamId || '',
1404
+ function buildRemoteAudioBinaryPacket(audioEvent) {
1405
+ const payload = audioEvent?.payload;
1406
+ if (!Buffer.isBuffer(payload) || payload.length === 0) {
1407
+ return null;
1408
+ }
1409
+ const frame = audioEvent.frame || {};
1410
+ const audioStatus = audioEvent.audioStatus === true;
1411
+ const metadata = {
1412
+ type: audioStatus ? 'remote.audio.status' : 'remote.audio.binary',
1413
+ frameKind: audioStatus ? 'audio-status' : 'audio',
1414
+ deviceId: audioEvent.deviceId || frame.deviceId || '',
1415
+ frameSeq: Number(frame.frameSeq || 0) || 0,
1416
+ streamId: frame.streamId || '',
1368
1417
  mimeType: audioEvent.mimeType || frame.mimeType || frame.format || 'audio/webm;codecs=opus',
1369
1418
  sampleRate: Number(frame.sampleRate || 0) || 48000,
1370
1419
  channels: Number(frame.channels || 0) || 2,
1371
1420
  capturedAt: frame.capturedAt || '',
1372
1421
  receivedAt: frame.receivedAt || '',
1373
- byteLength: Number(audioEvent.byteLength || payload.length) || payload.length
1374
- };
1422
+ byteLength: Number(audioEvent.byteLength || payload.length) || payload.length,
1423
+ ...(audioStatus ? {
1424
+ commandId: frame.commandId || '',
1425
+ captureAlive: frame.captureAlive === true,
1426
+ helperAlive: frame.helperAlive === true,
1427
+ helperPid: Number(frame.helperPid || 0) || 0,
1428
+ captureGeneration: Number(frame.captureGeneration || 0) || 0,
1429
+ lastPcmAtEpochMs: Number(frame.lastPcmAtEpochMs || 0) || 0,
1430
+ silentForMs: Math.max(0, Number(frame.silentForMs || 0) || 0),
1431
+ restartCount: Math.max(0, Number(frame.restartCount || 0) || 0),
1432
+ error: String(frame.error || ''),
1433
+ statusAtEpochMs: Number(frame.statusAtEpochMs || 0) || Date.now()
1434
+ } : {})
1435
+ };
1375
1436
  const metaBuffer = Buffer.from(JSON.stringify(metadata), 'utf8');
1376
1437
  const header = Buffer.allocUnsafe(4);
1377
1438
  header.writeUInt32BE(metaBuffer.length, 0);
@@ -1523,28 +1584,42 @@ function sendMode4AtlasFrame(ws, output) {
1523
1584
  }
1524
1585
  }
1525
1586
 
1526
- function configureMode4Atlas(ws, payload = {}) {
1527
- const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
1528
- const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
1587
+ function configureMode4Atlas(ws, payload = {}) {
1588
+ const deviceIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId).slice(0, 100);
1589
+ const captureDeviceIds = normalizeDeviceIds(payload.captureDeviceIds ?? payload.inputDeviceIds ?? deviceIds)
1590
+ .filter(deviceId => deviceIds.includes(deviceId));
1591
+ const monitorSelections = normalizeMonitorSelections(payload.monitorSelections);
1529
1592
  const sharpTiles = Number(payload.tileWidth) >= 480 || Number(payload.tileHeight) >= 270;
1530
1593
  const tileWidth = sharpTiles ? 480 : 320;
1531
1594
  const tileHeight = sharpTiles ? 270 : 180;
1532
- ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
1533
- ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
1534
- ws.liveDeskAtlasSession.configure({
1535
- deviceIds,
1595
+ const previousInputDeviceIds = ws.liveDeskAtlasInputDeviceIds instanceof Set
1596
+ ? new Set(ws.liveDeskAtlasInputDeviceIds)
1597
+ : new Set(deviceIds);
1598
+ ws.liveDeskAtlasDeviceIds = new Set(deviceIds);
1599
+ ws.liveDeskAtlasInputDeviceIds = new Set(captureDeviceIds);
1600
+ ws.liveDeskAtlasInputOptions = { tileWidth, tileHeight, monitorSelections };
1601
+ ws.liveDeskAtlasSession.configure({
1602
+ deviceIds,
1603
+ inputDeviceIds: captureDeviceIds,
1536
1604
  width: clampNumber(payload.width, 640, 3840, 1920),
1537
1605
  height: clampNumber(payload.height, 360, 2160, 1080),
1538
1606
  tileWidth,
1539
1607
  tileHeight,
1540
1608
  fps: clampNumber(payload.fps, 1, 30, 20)
1541
1609
  });
1542
- for (const deviceId of deviceIds) {
1543
- startMode4AtlasInput(ws, deviceId, 'atlas-configure');
1544
- }
1610
+ for (const deviceId of previousInputDeviceIds) {
1611
+ if (ws.liveDeskAtlasInputDeviceIds.has(deviceId)) {
1612
+ continue;
1613
+ }
1614
+ stopMode4AtlasInput(ws, deviceId, 'atlas-input-paused');
1615
+ }
1616
+ for (const deviceId of captureDeviceIds) {
1617
+ startMode4AtlasInput(ws, deviceId, 'atlas-configure');
1618
+ }
1545
1619
  sendJson(ws, {
1546
- type: 'Mode4AtlasSubscription',
1547
- deviceIds,
1620
+ type: 'Mode4AtlasSubscription',
1621
+ deviceIds,
1622
+ captureDeviceIds,
1548
1623
  inputMode: 'mode2-lzo',
1549
1624
  outputMode: 'mode4-h264-atlas',
1550
1625
  tileWidth,
@@ -1553,10 +1628,10 @@ function configureMode4Atlas(ws, payload = {}) {
1553
1628
  });
1554
1629
  }
1555
1630
 
1556
- function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
1557
- const options = ws?.liveDeskAtlasInputOptions;
1558
- if (!options || !ws.liveDeskAtlasDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
1559
- return remoteHub.startLiveStream(deviceId, {
1631
+ function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
1632
+ const options = ws?.liveDeskAtlasInputOptions;
1633
+ if (!options || !ws.liveDeskAtlasInputDeviceIds?.has(deviceId)) return { ok: false, error: 'atlas-device-not-configured' };
1634
+ return remoteHub.startLiveStream(deviceId, {
1560
1635
  fps: 8,
1561
1636
  maxWidth: options.tileWidth,
1562
1637
  maxHeight: options.tileHeight,
@@ -1568,8 +1643,30 @@ function startMode4AtlasInput(ws, deviceId, reason = 'atlas-configure') {
1568
1643
  reuseExisting: false,
1569
1644
  silentReuse: true,
1570
1645
  restartReason: reason
1571
- });
1572
- }
1646
+ });
1647
+ }
1648
+
1649
+ function hasOtherAtlasInputOwner(ws, deviceId) {
1650
+ for (const candidate of atlasClients.keys()) {
1651
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
1652
+ continue;
1653
+ }
1654
+ if (candidate.liveDeskAtlasInputDeviceIds?.has(deviceId)) {
1655
+ return true;
1656
+ }
1657
+ }
1658
+ return false;
1659
+ }
1660
+
1661
+ function stopMode4AtlasInput(ws, deviceId, reason = 'atlas-input-stopped') {
1662
+ if (!deviceId || hasOtherAtlasInputOwner(ws, deviceId)) {
1663
+ return { ok: true, alreadyOwned: true };
1664
+ }
1665
+ return remoteHub.stopLiveStream(deviceId, {
1666
+ streamPurpose: 'atlas',
1667
+ reason
1668
+ });
1669
+ }
1573
1670
 
1574
1671
  function wantsBinaryFrame(req) {
1575
1672
  return /^(1|true|yes|on|binary|raw)$/i.test(String(req.query?.binary ?? req.query?.raw ?? ''))
@@ -2443,9 +2540,13 @@ atlasWss.on('connection', ws => {
2443
2540
  });
2444
2541
  ws.liveDeskAtlasSession = session;
2445
2542
  atlasClients.set(ws, session);
2446
- const cleanup = () => {
2447
- atlasClients.delete(ws);
2448
- session.close();
2543
+ const cleanup = () => {
2544
+ atlasClients.delete(ws);
2545
+ for (const deviceId of ws.liveDeskAtlasInputDeviceIds || []) {
2546
+ stopMode4AtlasInput(ws, deviceId, 'atlas-client-closed');
2547
+ }
2548
+ ws.liveDeskAtlasInputDeviceIds = new Set();
2549
+ session.close();
2449
2550
  const lane = ws.liveDeskFrameSendLane;
2450
2551
  if (lane) {
2451
2552
  lane.queue.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.224",
3
+ "version": "0.1.226",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
34
- "@livedesk/client": "0.1.127",
34
+ "@livedesk/client": "0.1.129",
35
35
  "@openai/codex-sdk": "0.144.5",
36
36
  "cors": "^2.8.5",
37
37
  "express": "^4.21.2",