instbyte 1.9.3 → 1.10.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.
- package/README.md +49 -132
- package/bin/instbyte.js +1 -0
- package/client/css/app.css +522 -0
- package/client/index.html +38 -1
- package/client/js/app.js +557 -18
- package/client/sw.js +10 -0
- package/package.json +1 -1
- package/server/server.js +237 -7
package/client/js/app.js
CHANGED
|
@@ -6,6 +6,27 @@ let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
|
|
|
6
6
|
const newItemIds = new Set();
|
|
7
7
|
const seenEmitted = new Set(); // item IDs this session has already emitted seen for
|
|
8
8
|
|
|
9
|
+
// BROADCAST STATE
|
|
10
|
+
let isBroadcasting = false;
|
|
11
|
+
let broadcastStream = null;
|
|
12
|
+
let broadcastChannel = 'general';
|
|
13
|
+
let audioCtx = null;
|
|
14
|
+
|
|
15
|
+
// WebRTC — broadcaster maintains one peer connection per viewer
|
|
16
|
+
// key: viewerId (socket id), value: RTCPeerConnection
|
|
17
|
+
const peerConnections = new Map();
|
|
18
|
+
|
|
19
|
+
// WebRTC — viewer holds a single peer connection to broadcaster
|
|
20
|
+
let viewerPeerConnection = null;
|
|
21
|
+
let broadcasterId = null;
|
|
22
|
+
|
|
23
|
+
const STUN_SERVERS = {
|
|
24
|
+
iceServers: [
|
|
25
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
26
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
|
|
9
30
|
const seenObserver = new IntersectionObserver((entries) => {
|
|
10
31
|
entries.forEach(entry => {
|
|
11
32
|
if (!entry.isIntersecting) return;
|
|
@@ -116,6 +137,9 @@ async function applyBranding() {
|
|
|
116
137
|
root.style.setProperty("--color-secondary-light", p.secondaryLight);
|
|
117
138
|
root.style.setProperty("--color-on-secondary", p.onSecondary);
|
|
118
139
|
|
|
140
|
+
const themeMeta = document.getElementById('themeColorMeta');
|
|
141
|
+
if (themeMeta) themeMeta.setAttribute('content', p.primary);
|
|
142
|
+
|
|
119
143
|
} catch (e) {
|
|
120
144
|
// Branding failed — default styles remain, no crash
|
|
121
145
|
}
|
|
@@ -158,7 +182,7 @@ function escapeHtml(str) {
|
|
|
158
182
|
|
|
159
183
|
function playChime() {
|
|
160
184
|
try {
|
|
161
|
-
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
185
|
+
const ctx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
|
162
186
|
|
|
163
187
|
const gain = ctx.createGain();
|
|
164
188
|
gain.connect(ctx.destination);
|
|
@@ -210,18 +234,30 @@ function renderText(text) {
|
|
|
210
234
|
if (!text) return "";
|
|
211
235
|
if (looksLikeMarkdown(text)) {
|
|
212
236
|
const html = marked.parse(text);
|
|
213
|
-
// highlight code blocks after parse
|
|
214
237
|
const wrap = document.createElement("div");
|
|
215
238
|
wrap.innerHTML = html;
|
|
216
239
|
wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
|
|
217
240
|
return `<div class="markdown-body">${wrap.innerHTML}</div>`;
|
|
218
241
|
}
|
|
219
|
-
// plain text —
|
|
220
|
-
|
|
242
|
+
// plain text — escape and preserve newlines
|
|
243
|
+
const escaped = text
|
|
221
244
|
.replace(/&/g, "&")
|
|
222
245
|
.replace(/</g, "<")
|
|
223
246
|
.replace(/>/g, ">")
|
|
224
247
|
.replace(/\n/g, "<br>");
|
|
248
|
+
|
|
249
|
+
// collapse long plain text (more than 8 newlines)
|
|
250
|
+
const lineCount = (text.match(/\n/g) || []).length;
|
|
251
|
+
if (lineCount > 8) {
|
|
252
|
+
const id = 'expand-' + Math.random().toString(36).slice(2, 8);
|
|
253
|
+
return `<div class="item-text-collapsed" id="${id}">${escaped}</div>
|
|
254
|
+
<button class="item-text-expand-btn" onclick="
|
|
255
|
+
document.getElementById('${id}').classList.remove('item-text-collapsed');
|
|
256
|
+
this.remove();
|
|
257
|
+
">Show more</button>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return escaped;
|
|
225
261
|
}
|
|
226
262
|
|
|
227
263
|
const TEXT_EXTENSIONS = [
|
|
@@ -452,6 +488,7 @@ function toggleMoveDropdown(e, id, currentChannel) {
|
|
|
452
488
|
|
|
453
489
|
dropdown.classList.add("open");
|
|
454
490
|
openDropdown = dropdown;
|
|
491
|
+
if (window.innerWidth <= 640) openBottomSheet(dropdown);
|
|
455
492
|
}
|
|
456
493
|
|
|
457
494
|
function toggleMoreMenu(e, id, currentChannel) {
|
|
@@ -1255,7 +1292,7 @@ document.addEventListener("paste", async e => {
|
|
|
1255
1292
|
});
|
|
1256
1293
|
});
|
|
1257
1294
|
|
|
1258
|
-
async function uploadFiles(files) {
|
|
1295
|
+
async function uploadFiles(files, overrideChannel) {
|
|
1259
1296
|
if (!files || !files.length) return;
|
|
1260
1297
|
|
|
1261
1298
|
const status = document.getElementById("uploadStatus");
|
|
@@ -1263,7 +1300,7 @@ async function uploadFiles(files) {
|
|
|
1263
1300
|
const text = document.getElementById("uploadText");
|
|
1264
1301
|
|
|
1265
1302
|
const total = files.length;
|
|
1266
|
-
const targetChannel = channel; // capture at start, won't change if user switches
|
|
1303
|
+
const targetChannel = overrideChannel || channel; // capture at start, won't change if user switches
|
|
1267
1304
|
|
|
1268
1305
|
for (let i = 0; i < total; i++) {
|
|
1269
1306
|
const file = files[i];
|
|
@@ -1315,6 +1352,7 @@ async function uploadFiles(files) {
|
|
|
1315
1352
|
setTimeout(resolve, 1200);
|
|
1316
1353
|
return;
|
|
1317
1354
|
}
|
|
1355
|
+
if (navigator.vibrate) navigator.vibrate([50]);
|
|
1318
1356
|
resolve();
|
|
1319
1357
|
};
|
|
1320
1358
|
|
|
@@ -1476,21 +1514,27 @@ function showChannelMenu(e, ch) {
|
|
|
1476
1514
|
</button>
|
|
1477
1515
|
`;
|
|
1478
1516
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1517
|
+
if (window.innerWidth <= 640) {
|
|
1518
|
+
// mobile — bottom sheet, no positioning needed
|
|
1519
|
+
menu.classList.add("open");
|
|
1520
|
+
openBottomSheet(menu);
|
|
1521
|
+
} else {
|
|
1522
|
+
// desktop — position by click coordinates
|
|
1523
|
+
menu.style.top = e.clientY + "px";
|
|
1524
|
+
menu.style.left = e.clientX + "px";
|
|
1525
|
+
menu.classList.add("open");
|
|
1526
|
+
requestAnimationFrame(() => {
|
|
1527
|
+
const rect = menu.getBoundingClientRect();
|
|
1528
|
+
if (rect.right > window.innerWidth - 8)
|
|
1529
|
+
menu.style.left = (e.clientX - rect.width) + "px";
|
|
1530
|
+
if (rect.bottom > window.innerHeight - 8)
|
|
1531
|
+
menu.style.top = (e.clientY - rect.height) + "px";
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1490
1534
|
}
|
|
1491
1535
|
|
|
1492
1536
|
document.addEventListener("click", () => {
|
|
1493
|
-
|
|
1537
|
+
closeAllBottomSheets();
|
|
1494
1538
|
});
|
|
1495
1539
|
|
|
1496
1540
|
document.addEventListener("contextmenu", (e) => {
|
|
@@ -1635,6 +1679,494 @@ document.addEventListener("keydown", e => {
|
|
|
1635
1679
|
|
|
1636
1680
|
});
|
|
1637
1681
|
|
|
1682
|
+
// Create shared backdrop for bottom sheets
|
|
1683
|
+
const bottomSheetBackdrop = document.createElement('div');
|
|
1684
|
+
bottomSheetBackdrop.className = 'bottom-sheet-backdrop';
|
|
1685
|
+
document.body.appendChild(bottomSheetBackdrop);
|
|
1686
|
+
|
|
1687
|
+
function openBottomSheet(el) {
|
|
1688
|
+
bottomSheetBackdrop.classList.add('open');
|
|
1689
|
+
bottomSheetBackdrop.onclick = () => closeAllBottomSheets();
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function closeAllBottomSheets() {
|
|
1693
|
+
bottomSheetBackdrop.classList.remove('open');
|
|
1694
|
+
// close context menu
|
|
1695
|
+
document.getElementById('channelMenu').classList.remove('open');
|
|
1696
|
+
// close any open move dropdown
|
|
1697
|
+
if (openDropdown) {
|
|
1698
|
+
openDropdown.classList.remove('open');
|
|
1699
|
+
openDropdown = null;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ============================================================
|
|
1704
|
+
// BROADCAST
|
|
1705
|
+
// ============================================================
|
|
1706
|
+
|
|
1707
|
+
async function startBroadcast() {
|
|
1708
|
+
if (!window.isSecureContext) {
|
|
1709
|
+
showBroadcastSecureWarning();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const statusRes = await fetch('/broadcast/status');
|
|
1714
|
+
const status = await statusRes.json();
|
|
1715
|
+
if (status.live) {
|
|
1716
|
+
alert(`${status.uploader} is already broadcasting. Only one broadcast at a time.`);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const channelNames = channels.map(c => c.name);
|
|
1721
|
+
broadcastChannel = channelNames.includes('general') ? 'general' : channelNames[0];
|
|
1722
|
+
|
|
1723
|
+
let stream;
|
|
1724
|
+
try {
|
|
1725
|
+
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
1726
|
+
video: { frameRate: { ideal: 30 }, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
|
1727
|
+
audio: true
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// Mix in microphone if available — failure is silent, broadcast continues without mic
|
|
1731
|
+
try {
|
|
1732
|
+
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
1733
|
+
micStream.getAudioTracks().forEach(track => stream.addTrack(track));
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
// Mic denied or unavailable — continue without it
|
|
1736
|
+
}
|
|
1737
|
+
} catch (e) {
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const startRes = await fetch('/broadcast/start', {
|
|
1742
|
+
method: 'POST',
|
|
1743
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1744
|
+
body: JSON.stringify({ uploader, channel: broadcastChannel, socketId: socket.id })
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
if (!startRes.ok) {
|
|
1748
|
+
const err = await startRes.json();
|
|
1749
|
+
alert(err.error || 'Could not start broadcast');
|
|
1750
|
+
stream.getTracks().forEach(t => t.stop());
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
broadcastStream = stream;
|
|
1755
|
+
isBroadcasting = true;
|
|
1756
|
+
|
|
1757
|
+
try {
|
|
1758
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
1759
|
+
await audioCtx.resume();
|
|
1760
|
+
} catch (e) { }
|
|
1761
|
+
|
|
1762
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
1763
|
+
if (startBtn) {
|
|
1764
|
+
startBtn.textContent = '⏹ Stop';
|
|
1765
|
+
startBtn.classList.add('is-live');
|
|
1766
|
+
startBtn.onclick = stopBroadcast;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
stream.getVideoTracks()[0].addEventListener('ended', () => {
|
|
1770
|
+
stopBroadcast();
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
async function stopBroadcast() {
|
|
1775
|
+
if (!isBroadcasting) return;
|
|
1776
|
+
|
|
1777
|
+
isBroadcasting = false;
|
|
1778
|
+
|
|
1779
|
+
// Reset UI immediately — before any async calls that might fail
|
|
1780
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
1781
|
+
if (startBtn) {
|
|
1782
|
+
startBtn.textContent = '📡 Broadcast';
|
|
1783
|
+
startBtn.classList.remove('is-live');
|
|
1784
|
+
startBtn.onclick = startBroadcast;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Close all peer connections to viewers
|
|
1788
|
+
peerConnections.forEach(pc => pc.close());
|
|
1789
|
+
peerConnections.clear();
|
|
1790
|
+
|
|
1791
|
+
if (broadcastStream) {
|
|
1792
|
+
broadcastStream.getTracks().forEach(t => t.stop());
|
|
1793
|
+
broadcastStream = null;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Best effort — server may already be down
|
|
1797
|
+
try { await fetch('/broadcast/end', { method: 'POST' }); } catch (e) { }
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Called when broadcaster gets notified a new viewer joined
|
|
1801
|
+
async function handleViewerJoined(viewerId) {
|
|
1802
|
+
if (!broadcastStream) return;
|
|
1803
|
+
|
|
1804
|
+
const pc = new RTCPeerConnection(STUN_SERVERS);
|
|
1805
|
+
peerConnections.set(viewerId, pc);
|
|
1806
|
+
|
|
1807
|
+
// Add all tracks from the screen share stream
|
|
1808
|
+
broadcastStream.getTracks().forEach(track => {
|
|
1809
|
+
pc.addTrack(track, broadcastStream);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
// Send ICE candidates to this viewer as they're discovered
|
|
1813
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
1814
|
+
if (candidate) {
|
|
1815
|
+
socket.emit('webrtc-ice', { candidate, targetId: viewerId });
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
pc.onconnectionstatechange = () => {
|
|
1820
|
+
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
|
1821
|
+
pc.close();
|
|
1822
|
+
peerConnections.delete(viewerId);
|
|
1823
|
+
}
|
|
1824
|
+
};
|
|
1825
|
+
|
|
1826
|
+
// Create and send offer to viewer
|
|
1827
|
+
const offer = await pc.createOffer();
|
|
1828
|
+
await pc.setLocalDescription(offer);
|
|
1829
|
+
socket.emit('webrtc-offer', { offer, viewerId });
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function showBroadcastBar(data) {
|
|
1833
|
+
const bar = document.getElementById('broadcastBar');
|
|
1834
|
+
const label = document.getElementById('broadcastLabel');
|
|
1835
|
+
const endBtn = document.getElementById('broadcastEndBtn');
|
|
1836
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1837
|
+
|
|
1838
|
+
if (!bar) return;
|
|
1839
|
+
|
|
1840
|
+
label.textContent = `${data.uploader} is broadcasting`;
|
|
1841
|
+
bar.style.display = 'block';
|
|
1842
|
+
|
|
1843
|
+
// Show end button only to the broadcaster
|
|
1844
|
+
if (data.uploader === uploader) {
|
|
1845
|
+
endBtn.style.display = 'inline-block';
|
|
1846
|
+
joinBtn.style.display = 'none';
|
|
1847
|
+
} else {
|
|
1848
|
+
endBtn.style.display = 'none';
|
|
1849
|
+
joinBtn.style.display = 'inline-block';
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function hideBroadcastBar() {
|
|
1854
|
+
const bar = document.getElementById('broadcastBar');
|
|
1855
|
+
if (!bar) return;
|
|
1856
|
+
|
|
1857
|
+
// Show ended toast for 5 seconds then hide bar
|
|
1858
|
+
const label = document.getElementById('broadcastLabel');
|
|
1859
|
+
label.textContent = 'Broadcast ended';
|
|
1860
|
+
|
|
1861
|
+
setTimeout(() => {
|
|
1862
|
+
bar.style.display = 'none';
|
|
1863
|
+
}, 5000);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function joinBroadcast() {
|
|
1867
|
+
const panel = document.getElementById('viewerPanel');
|
|
1868
|
+
const label = document.getElementById('viewerLabel');
|
|
1869
|
+
if (!panel) return;
|
|
1870
|
+
|
|
1871
|
+
label.textContent = `${document.getElementById('broadcastLabel').textContent}`;
|
|
1872
|
+
panel.style.display = 'flex';
|
|
1873
|
+
|
|
1874
|
+
// Hide join button while viewing
|
|
1875
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1876
|
+
if (joinBtn) joinBtn.style.display = 'none';
|
|
1877
|
+
|
|
1878
|
+
// Reset position to default top-right on each join
|
|
1879
|
+
panel.style.left = '';
|
|
1880
|
+
panel.style.top = '';
|
|
1881
|
+
panel.style.right = '20px';
|
|
1882
|
+
|
|
1883
|
+
makeDraggable(panel);
|
|
1884
|
+
socket.emit('broadcast-join');
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function leaveBroadcast() {
|
|
1888
|
+
const panel = document.getElementById('viewerPanel');
|
|
1889
|
+
if (panel) panel.style.display = 'none';
|
|
1890
|
+
|
|
1891
|
+
// Restore join button
|
|
1892
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1893
|
+
if (joinBtn) joinBtn.style.display = 'inline-block';
|
|
1894
|
+
|
|
1895
|
+
// Reset audio button to muted state
|
|
1896
|
+
const audioBtn = document.getElementById('audioToggleBtn');
|
|
1897
|
+
if (audioBtn) {
|
|
1898
|
+
audioBtn.textContent = '🔇';
|
|
1899
|
+
audioBtn.title = 'Unmute audio';
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Clean up viewer peer connection
|
|
1903
|
+
if (viewerPeerConnection) {
|
|
1904
|
+
viewerPeerConnection.close();
|
|
1905
|
+
viewerPeerConnection = null;
|
|
1906
|
+
}
|
|
1907
|
+
broadcasterId = null;
|
|
1908
|
+
|
|
1909
|
+
// Stop video stream and reset audio on viewer element
|
|
1910
|
+
const video = document.getElementById('viewerFrame');
|
|
1911
|
+
if (video) {
|
|
1912
|
+
video.srcObject = null;
|
|
1913
|
+
video.muted = true;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function captureToChannel() {
|
|
1918
|
+
const video = document.getElementById('viewerFrame');
|
|
1919
|
+
if (!video || !video.srcObject) return;
|
|
1920
|
+
|
|
1921
|
+
// Draw current video frame to canvas and convert to blob
|
|
1922
|
+
const canvas = document.createElement('canvas');
|
|
1923
|
+
canvas.width = video.videoWidth || 1280;
|
|
1924
|
+
canvas.height = video.videoHeight || 720;
|
|
1925
|
+
const ctx = canvas.getContext('2d');
|
|
1926
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1927
|
+
|
|
1928
|
+
canvas.toBlob((blob) => {
|
|
1929
|
+
if (!blob) return;
|
|
1930
|
+
const file = new File([blob], `broadcast-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
1931
|
+
uploadFiles([file], broadcastChannel);
|
|
1932
|
+
}, 'image/jpeg', 0.9);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function raiseHand() {
|
|
1936
|
+
socket.emit('broadcast-reaction', { from: uploader });
|
|
1937
|
+
|
|
1938
|
+
// Visual feedback for the person raising hand
|
|
1939
|
+
const toast = document.createElement('div');
|
|
1940
|
+
toast.className = 'raise-hand-toast';
|
|
1941
|
+
toast.textContent = '✋ Raised hand';
|
|
1942
|
+
document.body.appendChild(toast);
|
|
1943
|
+
setTimeout(() => toast.remove(), 2000);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function showBroadcastSecureWarning() {
|
|
1947
|
+
const bar = document.getElementById('broadcastBar');
|
|
1948
|
+
const label = document.getElementById('broadcastLabel');
|
|
1949
|
+
if (!bar || !label) return;
|
|
1950
|
+
|
|
1951
|
+
bar.style.display = 'block';
|
|
1952
|
+
label.innerHTML = `Broadcasting requires HTTPS or localhost.
|
|
1953
|
+
<a href="https://github.com/mohitgauniyal/instbyte#reverse-proxy"
|
|
1954
|
+
target="_blank"
|
|
1955
|
+
style="color:#60a5fa;text-decoration:underline">
|
|
1956
|
+
Learn more
|
|
1957
|
+
</a>`;
|
|
1958
|
+
|
|
1959
|
+
// Hide after 6 seconds
|
|
1960
|
+
setTimeout(() => {
|
|
1961
|
+
bar.style.display = 'none';
|
|
1962
|
+
label.textContent = 'Someone is broadcasting';
|
|
1963
|
+
}, 6000);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function toggleMinimize() {
|
|
1967
|
+
const panel = document.getElementById('viewerPanel');
|
|
1968
|
+
if (!panel) return;
|
|
1969
|
+
panel.classList.toggle('minimized');
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function toggleAudio() {
|
|
1973
|
+
const video = document.getElementById('viewerFrame');
|
|
1974
|
+
const btn = document.getElementById('audioToggleBtn');
|
|
1975
|
+
if (!video || !btn) return;
|
|
1976
|
+
|
|
1977
|
+
video.muted = !video.muted;
|
|
1978
|
+
btn.textContent = video.muted ? '🔇' : '🔊';
|
|
1979
|
+
btn.title = video.muted ? 'Unmute audio' : 'Mute audio';
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function makeDraggable(panel) {
|
|
1983
|
+
const header = panel.querySelector('.viewer-panel-header');
|
|
1984
|
+
if (!header) return;
|
|
1985
|
+
|
|
1986
|
+
let isDragging = false;
|
|
1987
|
+
let startX, startY, startLeft, startTop;
|
|
1988
|
+
|
|
1989
|
+
header.addEventListener('mousedown', (e) => {
|
|
1990
|
+
// Don't drag if clicking a button
|
|
1991
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
1992
|
+
|
|
1993
|
+
isDragging = true;
|
|
1994
|
+
startX = e.clientX;
|
|
1995
|
+
startY = e.clientY;
|
|
1996
|
+
|
|
1997
|
+
const rect = panel.getBoundingClientRect();
|
|
1998
|
+
startLeft = rect.left;
|
|
1999
|
+
startTop = rect.top;
|
|
2000
|
+
|
|
2001
|
+
// Switch from right-anchored to left-anchored positioning
|
|
2002
|
+
panel.style.right = 'auto';
|
|
2003
|
+
panel.style.left = startLeft + 'px';
|
|
2004
|
+
panel.style.top = startTop + 'px';
|
|
2005
|
+
|
|
2006
|
+
e.preventDefault();
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
document.addEventListener('mousemove', (e) => {
|
|
2010
|
+
if (!isDragging) return;
|
|
2011
|
+
|
|
2012
|
+
const dx = e.clientX - startX;
|
|
2013
|
+
const dy = e.clientY - startY;
|
|
2014
|
+
|
|
2015
|
+
let newLeft = startLeft + dx;
|
|
2016
|
+
let newTop = startTop + dy;
|
|
2017
|
+
|
|
2018
|
+
// Keep within viewport
|
|
2019
|
+
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - panel.offsetWidth));
|
|
2020
|
+
newTop = Math.max(0, Math.min(newTop, window.innerHeight - panel.offsetHeight));
|
|
2021
|
+
|
|
2022
|
+
panel.style.left = newLeft + 'px';
|
|
2023
|
+
panel.style.top = newTop + 'px';
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
document.addEventListener('mouseup', () => {
|
|
2027
|
+
isDragging = false;
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// ─── SOCKET LISTENERS ────────────────────────────────────────
|
|
2032
|
+
|
|
2033
|
+
socket.on('broadcast-started', (data) => {
|
|
2034
|
+
showBroadcastBar(data);
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
socket.on('broadcast-ended', () => {
|
|
2038
|
+
hideBroadcastBar();
|
|
2039
|
+
leaveBroadcast();
|
|
2040
|
+
|
|
2041
|
+
if (isBroadcasting) {
|
|
2042
|
+
isBroadcasting = false;
|
|
2043
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
2044
|
+
if (startBtn) {
|
|
2045
|
+
startBtn.textContent = '📡 Broadcast';
|
|
2046
|
+
startBtn.classList.remove('is-live');
|
|
2047
|
+
startBtn.onclick = startBroadcast;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
// ─── WEBRTC — BROADCASTER SIDE ───────────────────────────────
|
|
2053
|
+
|
|
2054
|
+
// A new viewer joined — broadcaster initiates peer connection
|
|
2055
|
+
socket.on('webrtc-viewer-joined', ({ viewerId }) => {
|
|
2056
|
+
if (!isBroadcasting) return;
|
|
2057
|
+
handleViewerJoined(viewerId);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
// Broadcaster receives answer from a viewer
|
|
2061
|
+
socket.on('webrtc-answer', async ({ answer, viewerId }) => {
|
|
2062
|
+
const pc = peerConnections.get(viewerId);
|
|
2063
|
+
if (!pc) return;
|
|
2064
|
+
try {
|
|
2065
|
+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
2066
|
+
} catch (e) {
|
|
2067
|
+
console.error('Error setting remote description on broadcaster:', e);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
// ─── WEBRTC — VIEWER SIDE ────────────────────────────────────
|
|
2072
|
+
|
|
2073
|
+
// Viewer receives offer from broadcaster
|
|
2074
|
+
socket.on('webrtc-offer', async ({ offer, broadcasterId: bid }) => {
|
|
2075
|
+
broadcasterId = bid;
|
|
2076
|
+
|
|
2077
|
+
// Clean up any existing peer connection
|
|
2078
|
+
if (viewerPeerConnection) {
|
|
2079
|
+
viewerPeerConnection.close();
|
|
2080
|
+
viewerPeerConnection = null;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const pc = new RTCPeerConnection(STUN_SERVERS);
|
|
2084
|
+
viewerPeerConnection = pc;
|
|
2085
|
+
|
|
2086
|
+
// When stream arrives, attach to video element
|
|
2087
|
+
pc.ontrack = ({ streams }) => {
|
|
2088
|
+
const video = document.getElementById('viewerFrame');
|
|
2089
|
+
if (video && streams[0]) {
|
|
2090
|
+
video.srcObject = streams[0];
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// Send ICE candidates back to broadcaster
|
|
2095
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
2096
|
+
if (candidate) {
|
|
2097
|
+
socket.emit('webrtc-ice', { candidate, targetId: broadcasterId });
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
pc.onconnectionstatechange = () => {
|
|
2102
|
+
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
|
2103
|
+
pc.close();
|
|
2104
|
+
viewerPeerConnection = null;
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
2109
|
+
const answer = await pc.createAnswer();
|
|
2110
|
+
await pc.setLocalDescription(answer);
|
|
2111
|
+
socket.emit('webrtc-answer', { answer, broadcasterId });
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
// ICE candidates — both broadcaster and viewer use same handler
|
|
2115
|
+
socket.on('webrtc-ice', async ({ candidate, fromId }) => {
|
|
2116
|
+
// Broadcaster receives from viewer
|
|
2117
|
+
if (isBroadcasting) {
|
|
2118
|
+
const pc = peerConnections.get(fromId);
|
|
2119
|
+
if (pc) {
|
|
2120
|
+
try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { }
|
|
2121
|
+
}
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Viewer receives from broadcaster
|
|
2126
|
+
if (viewerPeerConnection) {
|
|
2127
|
+
try { await viewerPeerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { }
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
socket.on('disconnect', () => {
|
|
2132
|
+
// Trigger the same cleanup flow as a normal broadcast-ended event
|
|
2133
|
+
if (isBroadcasting || viewerPeerConnection) {
|
|
2134
|
+
isBroadcasting = false;
|
|
2135
|
+
|
|
2136
|
+
peerConnections.forEach(pc => pc.close());
|
|
2137
|
+
peerConnections.clear();
|
|
2138
|
+
|
|
2139
|
+
if (broadcastStream) {
|
|
2140
|
+
broadcastStream.getTracks().forEach(t => t.stop());
|
|
2141
|
+
broadcastStream = null;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
2145
|
+
if (startBtn) {
|
|
2146
|
+
startBtn.textContent = '📡 Broadcast';
|
|
2147
|
+
startBtn.classList.remove('is-live');
|
|
2148
|
+
startBtn.onclick = startBroadcast;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
hideBroadcastBar();
|
|
2152
|
+
leaveBroadcast();
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
// ─── RAISE HAND ──────────────────────────────────────────────
|
|
2157
|
+
|
|
2158
|
+
socket.on('broadcast-reaction-received', ({ from }) => {
|
|
2159
|
+
if (!isBroadcasting) return;
|
|
2160
|
+
|
|
2161
|
+
playChime();
|
|
2162
|
+
|
|
2163
|
+
const toast = document.createElement('div');
|
|
2164
|
+
toast.className = 'raise-hand-toast';
|
|
2165
|
+
toast.textContent = `✋ ${from} raised their hand`;
|
|
2166
|
+
document.body.appendChild(toast);
|
|
2167
|
+
setTimeout(() => toast.remove(), 8000);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
1638
2170
|
(async function init() {
|
|
1639
2171
|
await applyBranding();
|
|
1640
2172
|
await initName();
|
|
@@ -1646,4 +2178,11 @@ document.addEventListener("keydown", e => {
|
|
|
1646
2178
|
retention = info.retention; // null if "never", ms value otherwise
|
|
1647
2179
|
await loadChannels();
|
|
1648
2180
|
load();
|
|
2181
|
+
|
|
2182
|
+
// Check if a broadcast is already live when page loads
|
|
2183
|
+
const broadcastRes = await fetch('/broadcast/status');
|
|
2184
|
+
const broadcastStatus = await broadcastRes.json();
|
|
2185
|
+
if (broadcastStatus.live) {
|
|
2186
|
+
showBroadcastBar(broadcastStatus);
|
|
2187
|
+
}
|
|
1649
2188
|
})();
|
package/client/sw.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// sw.js — Instbyte service worker
|
|
2
|
+
// Minimal install-only service worker.
|
|
3
|
+
// We do not cache anything — Instbyte is a real-time LAN tool and must
|
|
4
|
+
// always fetch live data. This file exists solely to satisfy the PWA
|
|
5
|
+
// installability requirement.
|
|
6
|
+
|
|
7
|
+
self.addEventListener('install', () => self.skipWaiting());
|
|
8
|
+
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
|
|
9
|
+
|
|
10
|
+
// No fetch handler — all requests go straight to the network as normal.
|
package/package.json
CHANGED