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/README.md +24 -164
- package/bin/instbyte.js +15 -6
- package/client/css/app.css +366 -0
- package/client/index.html +31 -0
- package/client/js/app.js +498 -3
- package/package.json +2 -1
- package/server/config.js +15 -6
- package/server/server.js +271 -19
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.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|