loukai-app 0.4.2 → 0.5.0

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.
Files changed (30) hide show
  1. package/README.md +9 -3
  2. package/package.json +4 -3
  3. package/scripts/ensure-electron.js +164 -0
  4. package/src/main/handlers/index.js +5 -0
  5. package/src/main/handlers/streamingHandlers.js +70 -0
  6. package/src/main/preload.js +31 -0
  7. package/src/main/webServer.js +77 -1
  8. package/src/renderer/components/AppRoot.jsx +4 -0
  9. package/src/renderer/dist/assets/{kaiPlayer-DSaY7TxC.js → kaiPlayer-BsM-WzYQ.js} +2 -2
  10. package/src/renderer/dist/assets/kaiPlayer-BsM-WzYQ.js.map +1 -0
  11. package/src/renderer/dist/assets/streamingSender-HwIev870.js +2 -0
  12. package/src/renderer/dist/assets/streamingSender-HwIev870.js.map +1 -0
  13. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js +2 -0
  14. package/src/renderer/dist/assets/webrtcManager-CNNzvSgb.js.map +1 -0
  15. package/src/renderer/dist/renderer.js +15 -15
  16. package/src/renderer/dist/renderer.js.map +1 -1
  17. package/src/renderer/js/kaiPlayer.js +15 -0
  18. package/src/renderer/js/streamingSender.js +292 -0
  19. package/src/renderer/js/webrtcManager.js +3 -0
  20. package/src/shared/components/PlayerControls.jsx +12 -0
  21. package/src/shared/hooks/useStreamingSender.js +30 -0
  22. package/src/web/App.jsx +1 -0
  23. package/src/web/dist/assets/index-DUPLO3h6.js +11 -0
  24. package/src/web/dist/assets/{index-CGbmW1VG.js.map → index-DUPLO3h6.js.map} +1 -1
  25. package/src/web/dist/index.html +1 -1
  26. package/static/viewer.html +421 -0
  27. package/src/renderer/dist/assets/kaiPlayer-DSaY7TxC.js.map +0 -1
  28. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +0 -2
  29. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +0 -1
  30. package/src/web/dist/assets/index-CGbmW1VG.js +0 -11
@@ -30,6 +30,7 @@ export class KAIPlayer extends PlayerInterface {
30
30
  masterGain: null,
31
31
  analyser: null, // For butterchurn visualization
32
32
  vocalsPAGain: null, // For backup:PA feature - vocals to PA routing
33
+ streamDestination: null, // MediaStreamAudioDestinationNode for browser viewer streaming
33
34
  },
34
35
  IEM: {
35
36
  sourceNodes: new Map(),
@@ -106,6 +107,12 @@ export class KAIPlayer extends PlayerInterface {
106
107
  this.outputNodes.PA.vocalsPAGain.gain.value = 0; // Muted by default
107
108
  this.outputNodes.PA.vocalsPAGain.connect(this.outputNodes.PA.masterGain);
108
109
 
110
+ // Parallel MediaStream destination for browser viewer streaming.
111
+ // Connected to masterGain in addition to (not instead of) the audio device destination
112
+ // so PA continues playing locally while the viewer receives the same mix.
113
+ this.outputNodes.PA.streamDestination = this.audioContexts.PA.createMediaStreamDestination();
114
+ this.outputNodes.PA.masterGain.connect(this.outputNodes.PA.streamDestination);
115
+
109
116
  // Initialize IEM audio context with validated device
110
117
  const iemContextOptions = {};
111
118
  if (this.outputDevices.IEM !== 'default' && 'sinkId' in AudioContext.prototype) {
@@ -950,6 +957,14 @@ export class KAIPlayer extends PlayerInterface {
950
957
  return this.songData?.metadata?.duration || 0;
951
958
  }
952
959
 
960
+ /**
961
+ * Get the PA bus audio as a MediaStream, for piping into browser viewers via WebRTC.
962
+ * Returns null if audio hasn't initialized yet.
963
+ */
964
+ getPAStream() {
965
+ return this.outputNodes.PA.streamDestination?.stream ?? null;
966
+ }
967
+
953
968
  getMixerState() {
954
969
  return {
955
970
  PA: this.mixerState.PA,
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Streaming Sender - Broadcasts the karaoke canvas + PA audio to browser viewers via WebRTC.
3
+ *
4
+ * One renderer-side instance holds a Map<viewerId, RTCPeerConnection>. Each browser tab
5
+ * that opens the /viewer page (after admin auth) becomes a viewer with its own peer
6
+ * connection. The web server brokers signaling between this sender and each viewer
7
+ * over Socket.IO.
8
+ *
9
+ * Separate from webrtcManager.js (which handles the single Electron canvas window).
10
+ * Both can run simultaneously.
11
+ */
12
+
13
+ const ICE_SERVERS = []; // LAN-only by default; the web server is reachable so direct ICE works
14
+
15
+ export class StreamingSender {
16
+ constructor() {
17
+ /** @type {Map<string, RTCPeerConnection>} */
18
+ this.peers = new Map();
19
+ /** @type {Map<string, Array>} ICE candidates queued before remote description is set */
20
+ this.pendingICE = new Map();
21
+
22
+ this.canvasStream = null;
23
+ this.audioStream = null;
24
+
25
+ this._listenersBound = false;
26
+ }
27
+
28
+ /** Lazily capture the karaoke canvas video + PA bus audio. */
29
+ ensureSourceStreams() {
30
+ if (!this.canvasStream) {
31
+ const canvas = document.getElementById('karaokeCanvas');
32
+ if (!canvas) throw new Error('karaokeCanvas not found');
33
+ this.canvasStream = canvas.captureStream(60);
34
+ console.log(
35
+ '[stream-sender] captured karaokeCanvas, video tracks:',
36
+ this.canvasStream.getVideoTracks().length
37
+ );
38
+ }
39
+
40
+ if (!this.audioStream) {
41
+ // KAIPlayer is exposed via window.app.player.kaiPlayer (see ElectronBridge.js)
42
+ const paStream = window.app?.player?.kaiPlayer?.getPAStream?.();
43
+ if (paStream) {
44
+ this.audioStream = paStream;
45
+ console.log(
46
+ '[stream-sender] grabbed PA stream, audio tracks:',
47
+ paStream.getAudioTracks().length
48
+ );
49
+ } else {
50
+ console.warn('[stream-sender] 🔇 No PA stream; viewers will get video only');
51
+ }
52
+ }
53
+ }
54
+
55
+ /** Wire up IPC listeners for signaling events from the web server. Idempotent. */
56
+ bindSignalingListeners() {
57
+ if (this._listenersBound) return;
58
+ if (!window.kaiAPI?.streaming) {
59
+ console.error('[stream-sender] streaming IPC bridge not exposed');
60
+ return;
61
+ }
62
+
63
+ window.kaiAPI.streaming.onViewerJoin(({ viewerId }) => {
64
+ console.log('[stream-sender] viewer joined:', viewerId);
65
+ this.handleViewerJoin(viewerId);
66
+ });
67
+ window.kaiAPI.streaming.onViewerAnswer(({ viewerId, answer }) => {
68
+ console.log('[stream-sender] received answer from', viewerId);
69
+ this.handleViewerAnswer(viewerId, answer);
70
+ });
71
+ window.kaiAPI.streaming.onViewerICE(({ viewerId, candidate }) => {
72
+ this.handleViewerICE(viewerId, candidate);
73
+ });
74
+ window.kaiAPI.streaming.onViewerLeave(({ viewerId }) => {
75
+ console.log('[stream-sender] viewer left:', viewerId);
76
+ this.cleanupViewer(viewerId);
77
+ });
78
+
79
+ this._listenersBound = true;
80
+ console.log('[stream-sender] signaling listeners bound');
81
+ }
82
+
83
+ /** A new viewer joined — build a peer connection, attach tracks, send an offer. */
84
+ async handleViewerJoin(viewerId) {
85
+ try {
86
+ this.ensureSourceStreams();
87
+
88
+ const pc = new RTCPeerConnection({
89
+ iceServers: ICE_SERVERS,
90
+ iceCandidatePoolSize: 4,
91
+ bundlePolicy: 'balanced',
92
+ rtcpMuxPolicy: 'require',
93
+ });
94
+
95
+ this.peers.set(viewerId, pc);
96
+ this.pendingICE.set(viewerId, []);
97
+
98
+ // Video track from canvas
99
+ let videoSender = null;
100
+ let videoTransceiver = null;
101
+ this.canvasStream.getVideoTracks().forEach((track) => {
102
+ const tx = pc.addTransceiver(track, {
103
+ direction: 'sendonly',
104
+ streams: [this.canvasStream],
105
+ });
106
+ videoSender = tx.sender;
107
+ videoTransceiver = tx;
108
+ });
109
+
110
+ // Prefer H.264 baseline (hardware decode on virtually every device —
111
+ // phone GPUs, smart-TV browsers, Steam Deck). Fall back to VP8.
112
+ if (videoTransceiver && typeof videoTransceiver.setCodecPreferences === 'function') {
113
+ try {
114
+ const caps = RTCRtpSender.getCapabilities('video');
115
+ const preferred = (caps?.codecs ?? [])
116
+ .filter((c) => {
117
+ if (
118
+ c.mimeType.includes('H264') &&
119
+ c.sdpFmtpLine?.includes('profile-level-id=42e01f')
120
+ ) {
121
+ return true;
122
+ }
123
+ if (c.mimeType.includes('VP8')) return true;
124
+ return false;
125
+ })
126
+ .sort((a, _b) => (a.mimeType.includes('H264') ? -1 : 1));
127
+ if (preferred.length > 0) {
128
+ videoTransceiver.setCodecPreferences(preferred);
129
+ console.log(
130
+ '[stream-sender] preferred codecs:',
131
+ preferred.map((c) => c.mimeType).join(', ')
132
+ );
133
+ }
134
+ } catch (err) {
135
+ console.warn('[stream-sender] codec preference failed:', err);
136
+ }
137
+ }
138
+
139
+ // Audio track from PA bus
140
+ if (this.audioStream) {
141
+ this.audioStream.getAudioTracks().forEach((track) => {
142
+ pc.addTransceiver(track, { direction: 'sendonly', streams: [this.audioStream] });
143
+ });
144
+ }
145
+
146
+ // Apply video encoding parameters. getParameters() before negotiation
147
+ // can return an empty `encodings` array — retry after negotiation
148
+ // settles. The peers-map guard prevents these timers from running
149
+ // after the viewer has disconnected.
150
+ const applyVideoEncoding = async () => {
151
+ if (!this.peers.has(viewerId)) return;
152
+ if (!videoSender) return;
153
+ try {
154
+ const params = videoSender.getParameters();
155
+ params.degradationPreference = 'maintain-resolution';
156
+ if (params.encodings?.length) {
157
+ // High ceiling for LAN viewers; WebRTC's bandwidth estimator
158
+ // will adapt downward automatically for any viewer on a worse
159
+ // path (cellular, congested WiFi, slow Ethernet, etc.).
160
+ // No min-bitrate floor — would force quality loss to fail
161
+ // instead of degrade on poor links.
162
+ params.encodings[0].maxBitrate = 25_000_000; // 25 Mbps
163
+ params.encodings[0].maxFramerate = 60;
164
+ params.encodings[0].scaleResolutionDownBy = 1.0;
165
+ }
166
+ await videoSender.setParameters(params);
167
+ } catch (err) {
168
+ console.warn(`[stream-sender] setParameters for ${viewerId} failed:`, err.message);
169
+ }
170
+ };
171
+ applyVideoEncoding();
172
+ setTimeout(applyVideoEncoding, 2000);
173
+ setTimeout(applyVideoEncoding, 5000);
174
+
175
+ pc.onicecandidate = (event) => {
176
+ if (event.candidate) {
177
+ window.kaiAPI.streaming.sendViewerICE({
178
+ viewerId,
179
+ candidate: {
180
+ candidate: event.candidate.candidate,
181
+ sdpMid: event.candidate.sdpMid,
182
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
183
+ },
184
+ });
185
+ }
186
+ };
187
+
188
+ pc.onconnectionstatechange = () => {
189
+ if (
190
+ pc.connectionState === 'failed' ||
191
+ pc.connectionState === 'closed' ||
192
+ pc.connectionState === 'disconnected'
193
+ ) {
194
+ this.cleanupViewer(viewerId);
195
+ }
196
+ };
197
+
198
+ const offer = await pc.createOffer();
199
+ await pc.setLocalDescription(offer);
200
+
201
+ window.kaiAPI.streaming.sendViewerOffer({
202
+ viewerId,
203
+ offer: { type: offer.type, sdp: offer.sdp },
204
+ });
205
+ } catch (err) {
206
+ console.error(`Failed to handle viewer join (${viewerId}):`, err);
207
+ this.cleanupViewer(viewerId);
208
+ }
209
+ }
210
+
211
+ async handleViewerAnswer(viewerId, answer) {
212
+ const pc = this.peers.get(viewerId);
213
+ if (!pc) {
214
+ console.warn(`Answer for unknown viewer ${viewerId}`);
215
+ return;
216
+ }
217
+ try {
218
+ await pc.setRemoteDescription(new RTCSessionDescription(answer));
219
+
220
+ // Drain any ICE candidates that arrived before the remote description was set
221
+ const queue = this.pendingICE.get(viewerId) ?? [];
222
+ for (const candidate of queue) {
223
+ try {
224
+ // eslint-disable-next-line no-await-in-loop
225
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
226
+ } catch (err) {
227
+ console.warn('Queued ICE add failed:', err);
228
+ }
229
+ }
230
+ this.pendingICE.set(viewerId, []);
231
+ } catch (err) {
232
+ console.error(`Failed to set answer for ${viewerId}:`, err);
233
+ this.cleanupViewer(viewerId);
234
+ }
235
+ }
236
+
237
+ async handleViewerICE(viewerId, candidate) {
238
+ const pc = this.peers.get(viewerId);
239
+ if (!pc) return;
240
+
241
+ if (!pc.remoteDescription) {
242
+ this.pendingICE.get(viewerId)?.push(candidate);
243
+ return;
244
+ }
245
+
246
+ try {
247
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
248
+ } catch (err) {
249
+ console.warn(`Failed to add ICE for ${viewerId}:`, err);
250
+ }
251
+ }
252
+
253
+ cleanupViewer(viewerId) {
254
+ const pc = this.peers.get(viewerId);
255
+ if (pc) {
256
+ try {
257
+ pc.close();
258
+ } catch {
259
+ // already closed
260
+ }
261
+ this.peers.delete(viewerId);
262
+ }
263
+ this.pendingICE.delete(viewerId);
264
+ }
265
+
266
+ cleanupAll() {
267
+ for (const viewerId of this.peers.keys()) {
268
+ this.cleanupViewer(viewerId);
269
+ }
270
+
271
+ if (this.canvasStream) {
272
+ this.canvasStream.getTracks().forEach((t) => t.stop());
273
+ this.canvasStream = null;
274
+ }
275
+ // audioStream is owned by kaiPlayer — don't stop its tracks
276
+ this.audioStream = null;
277
+ }
278
+
279
+ getStats() {
280
+ return {
281
+ viewerCount: this.peers.size,
282
+ viewers: Array.from(this.peers.entries()).map(([id, pc]) => ({
283
+ id,
284
+ connectionState: pc.connectionState,
285
+ iceConnectionState: pc.iceConnectionState,
286
+ })),
287
+ };
288
+ }
289
+ }
290
+
291
+ const streamingSender = new StreamingSender();
292
+ export default streamingSender;
@@ -95,6 +95,9 @@ export class WebRTCManager {
95
95
 
96
96
  // Set encoding parameters to maintain resolution
97
97
  const setEncodingParams = async () => {
98
+ // Don't run if the sender's PC has been torn down (the retry
99
+ // timers below outlive cleanupSender).
100
+ if (!this.senderPC || this.senderPC.connectionState === 'closed') return;
98
101
  try {
99
102
  const params = sender.getParameters();
100
103
 
@@ -21,6 +21,7 @@ export function PlayerControls({
21
21
  onPreviousEffect,
22
22
  onNextEffect,
23
23
  onOpenCanvasWindow,
24
+ onOpenViewer,
24
25
  className = '',
25
26
  }) {
26
27
  const { isPlaying, position = 0, duration = 0 } = playback || {};
@@ -200,6 +201,17 @@ export function PlayerControls({
200
201
  </span>
201
202
  </button>
202
203
  )}
204
+ {onOpenViewer && (
205
+ <button
206
+ onClick={onOpenViewer}
207
+ className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition flex items-center justify-center"
208
+ title="Open Browser Viewer"
209
+ >
210
+ <span className="material-icons text-gray-700 dark:text-gray-300 leading-none">
211
+ open_in_new
212
+ </span>
213
+ </button>
214
+ )}
203
215
  </div>
204
216
  </div>
205
217
  );
@@ -0,0 +1,30 @@
1
+ /**
2
+ * useStreamingSender - browser-viewer broadcast sender
3
+ *
4
+ * Loads streamingSender on mount and binds its signaling listeners to the
5
+ * IPC bridge so that viewer-join/answer/ice/leave events from the embedded
6
+ * web server are handled. The sender stays idle until the first viewer joins.
7
+ */
8
+
9
+ import { useEffect } from 'react';
10
+
11
+ export function useStreamingSender() {
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+
15
+ (async () => {
16
+ try {
17
+ const module = await import('../../renderer/js/streamingSender.js');
18
+ if (cancelled) return;
19
+ module.default.bindSignalingListeners();
20
+ console.log('✅ Streaming sender initialized');
21
+ } catch (err) {
22
+ console.error('Failed to initialize streaming sender:', err);
23
+ }
24
+ })();
25
+
26
+ return () => {
27
+ cancelled = true;
28
+ };
29
+ }, []);
30
+ }
package/src/web/App.jsx CHANGED
@@ -477,6 +477,7 @@ export function App() {
477
477
  onSeek={handleSeek}
478
478
  onPreviousEffect={handleEffectPrevious}
479
479
  onNextEffect={handleEffectNext}
480
+ onOpenViewer={() => window.open('/viewer', '_blank', 'noopener')}
480
481
  />
481
482
  </div>
482
483
  <div className="flex gap-4 flex-1 min-h-0 overflow-hidden">