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/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 — just escape and preserve newlines
220
- return text
242
+ // plain text — escape and preserve newlines
243
+ const escaped = text
221
244
  .replace(/&/g, "&amp;")
222
245
  .replace(/</g, "&lt;")
223
246
  .replace(/>/g, "&gt;")
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
- menu.style.top = e.clientY + "px";
1480
- menu.style.left = e.clientX + "px";
1481
- menu.classList.add("open");
1482
-
1483
- requestAnimationFrame(() => {
1484
- const rect = menu.getBoundingClientRect();
1485
- if (rect.right > window.innerWidth - 8)
1486
- menu.style.left = (e.clientX - rect.width) + "px";
1487
- if (rect.bottom > window.innerHeight - 8)
1488
- menu.style.top = (e.clientY - rect.height) + "px";
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
- document.getElementById("channelMenu").classList.remove("open");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instbyte",
3
- "version": "1.9.3",
3
+ "version": "1.10.0",
4
4
  "description": "A self-hosted LAN sharing utility for fast, frictionless file, link, and snippet exchange across devices — no cloud required.",
5
5
  "main": "bin/instbyte.js",
6
6
  "bin": {