instbyte 1.9.4 → 1.11.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;
@@ -161,7 +182,7 @@ function escapeHtml(str) {
161
182
 
162
183
  function playChime() {
163
184
  try {
164
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
185
+ const ctx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
165
186
 
166
187
  const gain = ctx.createGain();
167
188
  gain.connect(ctx.destination);
@@ -1271,7 +1292,7 @@ document.addEventListener("paste", async e => {
1271
1292
  });
1272
1293
  });
1273
1294
 
1274
- async function uploadFiles(files) {
1295
+ async function uploadFiles(files, overrideChannel) {
1275
1296
  if (!files || !files.length) return;
1276
1297
 
1277
1298
  const status = document.getElementById("uploadStatus");
@@ -1279,7 +1300,7 @@ async function uploadFiles(files) {
1279
1300
  const text = document.getElementById("uploadText");
1280
1301
 
1281
1302
  const total = files.length;
1282
- 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
1283
1304
 
1284
1305
  for (let i = 0; i < total; i++) {
1285
1306
  const file = files[i];
@@ -1679,6 +1700,473 @@ function closeAllBottomSheets() {
1679
1700
  }
1680
1701
  }
1681
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
+
1682
2170
  (async function init() {
1683
2171
  await applyBranding();
1684
2172
  await initName();
@@ -1690,4 +2178,11 @@ function closeAllBottomSheets() {
1690
2178
  retention = info.retention; // null if "never", ms value otherwise
1691
2179
  await loadChannels();
1692
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
+ }
1693
2188
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instbyte",
3
- "version": "1.9.4",
3
+ "version": "1.11.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": {
@@ -43,6 +43,7 @@
43
43
  "express-rate-limit": "^7.1.5",
44
44
  "helmet": "^8.1.0",
45
45
  "multer": "^2.0.2",
46
+ "multicast-dns": "^7.2.5",
46
47
  "sharp": "^0.33.2",
47
48
  "socket.io": "^4.6.1",
48
49
  "sqlite3": "^5.1.6"
package/server/config.js CHANGED
@@ -43,12 +43,21 @@ function loadConfig() {
43
43
  let userConfig = {};
44
44
 
45
45
  if (fs.existsSync(configPath)) {
46
- try {
47
- const raw = fs.readFileSync(configPath, "utf-8");
48
- userConfig = JSON.parse(raw);
49
- console.log("Config loaded from instbyte.config.json");
50
- } catch (e) {
51
- console.warn("Warning: instbyte.config.json is invalid JSON, using defaults.");
46
+ if (fs.statSync(configPath).isDirectory()) {
47
+ console.error(
48
+ "Error: instbyte.config.json is a directory, not a file.\n" +
49
+ "This usually happens in Docker when the config file doesn't exist on the host before the container starts.\n" +
50
+ "Fix: stop the container, run `rm -rf instbyte.config.json && touch instbyte.config.json`, then start again.\n" +
51
+ "Using defaults for now."
52
+ );
53
+ } else {
54
+ try {
55
+ const raw = fs.readFileSync(configPath, "utf-8");
56
+ userConfig = JSON.parse(raw);
57
+ console.log("Config loaded from instbyte.config.json");
58
+ } catch (e) {
59
+ console.warn("Warning: instbyte.config.json is invalid JSON, using defaults.");
60
+ }
52
61
  }
53
62
  }
54
63