Skip to content

Commit 09e6f83

Browse files
committed
feat: add broadcast JS — broadcaster, viewer, filmstrip, change detection, raise hand, and page load status check
1 parent f5d22b4 commit 09e6f83

File tree

2 files changed

+300
-6
lines changed

2 files changed

+300
-6
lines changed

client/js/app.js

Lines changed: 300 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
66
const newItemIds = new Set();
77
const seenEmitted = new Set(); // item IDs this session has already emitted seen for
88

9+
// BROADCAST STATE
10+
let isBroadcasting = false;
11+
let broadcastStream = null;
12+
let broadcastCanvas = null;
13+
let broadcastCtx = null;
14+
let broadcastInterval = null;
15+
let lastFrameData = null;
16+
let broadcastChannel = 'general';
17+
let viewerCount = 0;
18+
919
const seenObserver = new IntersectionObserver((entries) => {
1020
entries.forEach(entry => {
1121
if (!entry.isIntersecting) return;
@@ -1271,15 +1281,15 @@ document.addEventListener("paste", async e => {
12711281
});
12721282
});
12731283

1274-
async function uploadFiles(files) {
1284+
async function uploadFiles(files, overrideChannel) {
12751285
if (!files || !files.length) return;
12761286

12771287
const status = document.getElementById("uploadStatus");
12781288
const bar = document.getElementById("uploadBar");
12791289
const text = document.getElementById("uploadText");
12801290

12811291
const total = files.length;
1282-
const targetChannel = channel; // capture at start, won't change if user switches
1292+
const targetChannel = overrideChannel || channel; // capture at start, won't change if user switches
12831293

12841294
for (let i = 0; i < total; i++) {
12851295
const file = files[i];
@@ -1679,6 +1689,287 @@ function closeAllBottomSheets() {
16791689
}
16801690
}
16811691

1692+
// ============================================================
1693+
// BROADCAST
1694+
// ============================================================
1695+
1696+
async function startBroadcast() {
1697+
// Check secure context first
1698+
if (!window.isSecureContext) {
1699+
showBroadcastSecureWarning();
1700+
return;
1701+
}
1702+
1703+
// Check if someone else is already broadcasting
1704+
const statusRes = await fetch('/broadcast/status');
1705+
const status = await statusRes.json();
1706+
if (status.live) {
1707+
alert(`${status.uploader} is already broadcasting. Only one broadcast at a time.`);
1708+
return;
1709+
}
1710+
1711+
// Ask which channel to save captures to
1712+
const channelNames = channels.map(c => c.name);
1713+
const chosen = channelNames.includes('general') ? 'general' : channelNames[0];
1714+
broadcastChannel = chosen;
1715+
1716+
// Request screen capture
1717+
let stream;
1718+
try {
1719+
stream = await navigator.mediaDevices.getDisplayMedia({
1720+
video: { frameRate: 2 },
1721+
audio: false
1722+
});
1723+
} catch (e) {
1724+
// User cancelled or permission denied — silent exit
1725+
return;
1726+
}
1727+
1728+
// Start broadcast on server
1729+
const startRes = await fetch('/broadcast/start', {
1730+
method: 'POST',
1731+
headers: { 'Content-Type': 'application/json' },
1732+
body: JSON.stringify({ uploader, channel: broadcastChannel })
1733+
});
1734+
1735+
if (!startRes.ok) {
1736+
const err = await startRes.json();
1737+
alert(err.error || 'Could not start broadcast');
1738+
stream.getTracks().forEach(t => t.stop());
1739+
return;
1740+
}
1741+
1742+
broadcastStream = stream;
1743+
isBroadcasting = true;
1744+
1745+
// Set up canvas for frame capture
1746+
broadcastCanvas = document.createElement('canvas');
1747+
broadcastCtx = broadcastCanvas.getContext('2d');
1748+
1749+
const video = document.createElement('video');
1750+
video.srcObject = stream;
1751+
video.play();
1752+
1753+
// Update start button
1754+
const startBtn = document.getElementById('startBroadcastBtn');
1755+
if (startBtn) {
1756+
startBtn.textContent = '⏹ Stop';
1757+
startBtn.classList.add('is-live');
1758+
startBtn.onclick = stopBroadcast;
1759+
}
1760+
1761+
// Frame capture loop — change detection, ~2fps
1762+
broadcastInterval = setInterval(() => {
1763+
if (!video.videoWidth) return;
1764+
broadcastCanvas.width = Math.min(video.videoWidth, 1280);
1765+
broadcastCanvas.height = Math.min(video.videoHeight, 720);
1766+
broadcastCtx.drawImage(video, 0, 0, broadcastCanvas.width, broadcastCanvas.height);
1767+
1768+
const frame = broadcastCanvas.toDataURL('image/jpeg', 0.5);
1769+
1770+
// Change detection — skip if frame is identical to last
1771+
if (frame === lastFrameData) return;
1772+
lastFrameData = frame;
1773+
1774+
socket.emit('broadcast-frame', { frame });
1775+
}, 500); // every 500ms = ~2fps
1776+
1777+
// If user stops sharing via browser UI (clicks "Stop sharing")
1778+
stream.getVideoTracks()[0].addEventListener('ended', () => {
1779+
stopBroadcast();
1780+
});
1781+
}
1782+
1783+
async function stopBroadcast() {
1784+
if (!isBroadcasting) return;
1785+
1786+
isBroadcasting = false;
1787+
1788+
// Stop the capture loop
1789+
if (broadcastInterval) {
1790+
clearInterval(broadcastInterval);
1791+
broadcastInterval = null;
1792+
}
1793+
1794+
// Stop all tracks
1795+
if (broadcastStream) {
1796+
broadcastStream.getTracks().forEach(t => t.stop());
1797+
broadcastStream = null;
1798+
}
1799+
1800+
lastFrameData = null;
1801+
1802+
// Tell server broadcast is over
1803+
await fetch('/broadcast/end', { method: 'POST' });
1804+
1805+
// Reset start button
1806+
const startBtn = document.getElementById('startBroadcastBtn');
1807+
if (startBtn) {
1808+
startBtn.textContent = '📡 Broadcast';
1809+
startBtn.classList.remove('is-live');
1810+
startBtn.onclick = startBroadcast;
1811+
}
1812+
}
1813+
1814+
function showBroadcastBar(data) {
1815+
const bar = document.getElementById('broadcastBar');
1816+
const label = document.getElementById('broadcastLabel');
1817+
const endBtn = document.getElementById('broadcastEndBtn');
1818+
const joinBtn = document.getElementById('broadcastJoinBtn');
1819+
1820+
if (!bar) return;
1821+
1822+
label.textContent = `${data.uploader} is broadcasting`;
1823+
bar.style.display = 'block';
1824+
1825+
// Show end button only to the broadcaster
1826+
if (data.uploader === uploader) {
1827+
endBtn.style.display = 'inline-block';
1828+
joinBtn.style.display = 'none';
1829+
} else {
1830+
endBtn.style.display = 'none';
1831+
joinBtn.style.display = 'inline-block';
1832+
}
1833+
}
1834+
1835+
function hideBroadcastBar() {
1836+
const bar = document.getElementById('broadcastBar');
1837+
if (!bar) return;
1838+
1839+
// Show ended toast for 5 seconds then hide bar
1840+
const label = document.getElementById('broadcastLabel');
1841+
label.textContent = 'Broadcast ended';
1842+
1843+
setTimeout(() => {
1844+
bar.style.display = 'none';
1845+
}, 5000);
1846+
}
1847+
1848+
function joinBroadcast() {
1849+
const panel = document.getElementById('viewerPanel');
1850+
const label = document.getElementById('viewerLabel');
1851+
if (!panel) return;
1852+
1853+
label.textContent = `${document.getElementById('broadcastLabel').textContent}`;
1854+
panel.style.display = 'flex';
1855+
1856+
// Tell server we joined — get last frame immediately
1857+
socket.emit('broadcast-join');
1858+
}
1859+
1860+
function leaveBroadcast() {
1861+
const panel = document.getElementById('viewerPanel');
1862+
if (panel) panel.style.display = 'none';
1863+
1864+
// Clear filmstrip
1865+
const filmstrip = document.getElementById('filmstrip');
1866+
if (filmstrip) filmstrip.innerHTML = '';
1867+
}
1868+
1869+
async function captureToChannel() {
1870+
const frame = document.getElementById('viewerFrame');
1871+
if (!frame || !frame.src || frame.src === window.location.href) return;
1872+
1873+
// Convert data URL to blob
1874+
const res = await fetch(frame.src);
1875+
const blob = await res.blob();
1876+
const file = new File([blob], `broadcast-${Date.now()}.jpg`, { type: 'image/jpeg' });
1877+
1878+
uploadFiles([file], broadcastChannel);
1879+
1880+
}
1881+
1882+
function raiseHand() {
1883+
socket.emit('broadcast-reaction', { from: uploader });
1884+
1885+
// Visual feedback for the person raising hand
1886+
const toast = document.createElement('div');
1887+
toast.className = 'raise-hand-toast';
1888+
toast.textContent = '✋ Raised hand';
1889+
document.body.appendChild(toast);
1890+
setTimeout(() => toast.remove(), 2000);
1891+
}
1892+
1893+
function showBroadcastSecureWarning() {
1894+
const bar = document.getElementById('broadcastBar');
1895+
const label = document.getElementById('broadcastLabel');
1896+
if (!bar || !label) return;
1897+
1898+
bar.style.display = 'block';
1899+
label.innerHTML = `Broadcasting requires HTTPS or localhost.
1900+
<a href="https://github.com/mohitgauniyal/instbyte#reverse-proxy"
1901+
target="_blank"
1902+
style="color:#60a5fa;text-decoration:underline">
1903+
Learn more
1904+
</a>`;
1905+
1906+
// Hide after 6 seconds
1907+
setTimeout(() => {
1908+
bar.style.display = 'none';
1909+
label.textContent = 'Someone is broadcasting';
1910+
}, 6000);
1911+
}
1912+
1913+
function updateFilmstrip(frame) {
1914+
const filmstrip = document.getElementById('filmstrip');
1915+
if (!filmstrip) return;
1916+
1917+
const img = document.createElement('img');
1918+
img.className = 'filmstrip-frame';
1919+
img.src = frame;
1920+
img.title = new Date().toLocaleTimeString();
1921+
img.onclick = () => {
1922+
const viewer = document.getElementById('viewerFrame');
1923+
if (viewer) viewer.src = frame;
1924+
};
1925+
1926+
filmstrip.appendChild(img);
1927+
filmstrip.scrollLeft = filmstrip.scrollWidth;
1928+
}
1929+
1930+
// ─── SOCKET LISTENERS ────────────────────────────────────────
1931+
1932+
socket.on('broadcast-started', (data) => {
1933+
showBroadcastBar(data);
1934+
});
1935+
1936+
socket.on('broadcast-ended', () => {
1937+
hideBroadcastBar();
1938+
leaveBroadcast();
1939+
1940+
// Reset start button if we were the broadcaster
1941+
if (isBroadcasting) {
1942+
isBroadcasting = false;
1943+
const startBtn = document.getElementById('startBroadcastBtn');
1944+
if (startBtn) {
1945+
startBtn.textContent = '📡 Broadcast';
1946+
startBtn.classList.remove('is-live');
1947+
startBtn.onclick = startBroadcast;
1948+
}
1949+
}
1950+
});
1951+
1952+
socket.on('broadcast-frame', (data) => {
1953+
const panel = document.getElementById('viewerPanel');
1954+
if (!panel || panel.style.display === 'none') return;
1955+
1956+
const viewer = document.getElementById('viewerFrame');
1957+
if (viewer) viewer.src = data.frame;
1958+
1959+
updateFilmstrip(data.frame);
1960+
});
1961+
1962+
socket.on('broadcast-reaction-received', ({ from }) => {
1963+
// Only show to broadcaster
1964+
if (!isBroadcasting) return;
1965+
1966+
const toast = document.createElement('div');
1967+
toast.className = 'raise-hand-toast';
1968+
toast.textContent = `✋ ${from} raised their hand`;
1969+
document.body.appendChild(toast);
1970+
setTimeout(() => toast.remove(), 3000);
1971+
});
1972+
16821973
(async function init() {
16831974
await applyBranding();
16841975
await initName();
@@ -1690,4 +1981,11 @@ function closeAllBottomSheets() {
16901981
retention = info.retention; // null if "never", ms value otherwise
16911982
await loadChannels();
16921983
load();
1984+
1985+
// Check if a broadcast is already live when page loads
1986+
const broadcastRes = await fetch('/broadcast/status');
1987+
const broadcastStatus = await broadcastRes.json();
1988+
if (broadcastStatus.live) {
1989+
showBroadcastBar(broadcastStatus);
1990+
}
16931991
})();

tests/integration/broadcast.test.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,12 @@
1010
import { describe, it, expect, beforeEach } from 'vitest'
1111
import request from 'supertest'
1212
import { setup, resetDb, getApp } from '../helpers/setup.js'
13-
import { createRequire } from 'module'
14-
15-
const require = createRequire(import.meta.url)
1613

1714
setup()
1815

1916
beforeEach(async () => {
2017
await resetDb()
2118
// reset broadcast state between tests
22-
const mod = require('../../server/server.js')
2319
// access and clear currentBroadcast via the end endpoint
2420
// if a broadcast is live from a previous test, end it cleanly
2521
await request(getApp()).post('/broadcast/end')

0 commit comments

Comments
 (0)