@@ -6,6 +6,16 @@ let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
66const newItemIds = new Set ( ) ;
77const 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+
919const 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} ) ( ) ;
0 commit comments