@@ -22,29 +22,36 @@
allowControl : false , quietUntil : 0 , detached : false , retry : null , pushTimer : null , driftTimer : null , lastPush : '' ,
loadingId : '' , restoring : false , inviteCode : '' , hiddenAt : 0 , present : null , presentHidden : false ,
voice : { on : false , stream : null , muted : false , pcs : new Map ( ) , audios : new Map ( ) } ,
minimized : false ,
minimized : false , tab : 'chat' , retries : 0 , lastSeekAt : 0 , pingAt : 0 , lastPong : 0 ,
gentle : ls . get ( 'ytpPartyGentle' ) !== 'false' , session : 0 , replaced : false ,
} ;
const inParty = ( ) => ! ! st . role ;
const isHost = ( ) => st . role === 'host' ;
const guestActive = ( ) => st . role === 'guest' && ! st . detached ;
const send = ( m ) => { if ( st . ws && st . ws . readyState === 1 ) st . ws . send ( JSON . stringify ( m ) ) ; } ;
const send = ( m ) => {
if ( navigator . onLine === false || ! st . ws || st . ws . readyState !== 1 || st . ws . bufferedAmount > 65536 ) return false ;
try { st . ws . send ( JSON . stringify ( m ) ) ; return true ; } catch { return false ; }
} ;
const serverNow = ( ) => Date . now ( ) + st . offset ;
const myName = ( ) => st . name || ( data . profile && data . profile . name ) || ls . get ( NAME _KEY ) || '' ;
// ---------- connection ----------
function connect ( ) {
clearTimeout ( st . retry ) ;
if ( navigator . onLine === false ) { setStatus ( 'Offline · waiting for your connection' ) ; return ; }
const old = st . ws ; st . ws = null ;
try { old ? . close ( ) ; } catch { /* gone */ }
const q = { role : st . role , pid , key : seatKey , name : myName ( ) || 'Guest' } ;
if ( st . code ) q . code = st . code ;
if ( isHost ( ) && st . secret ) q . secret = st . secret ;
const ws = new WebSocket ( wsUrl ( q ) ) ;
st . ws = ws ;
ws . onmessage = ( e ) => { let m ; try { m = JSON . parse ( e . data ) ; } catch { return ; } onMessage ( m ) ; } ;
st . ws = ws ; st . lastPong = Date . now ( ) ;
ws . onmessage = ( e ) => { if ( st . ws !== ws ) return ; let m ; try { m = JSON . parse ( e . data ) ; } catch { return ; } onMessage ( m ) ; } ;
ws . onclose = ( e ) => {
if ( st . ws !== ws ) return ;
st . ws = null ;
if ( ! inParty ( ) ) return ;
if ( e . code === 4000 ) { setStatus ( 'Opened on another tab or device — tap Re-sync to use this one' ) ; st . detached = true ; render ( ) ; return ; }
if ( e . code === 4000 ) { setStatus ( 'Opened on another tab or device — tap Re-sync to use this one' ) ; st . detached = true ; st . replaced = true ; render ( ) ; return ; }
if ( e . code === 4010 || e . code === 4004 || e . code === 4003 || e . code === 4005 ) {
const why = e . code === 4004 ? 'That party code doesn’ t exist (or has ended).' : e . code === 4003 ? 'This party belongs to another host.'
: e . code === 4005 ? 'Someone else is using this seat in the party.' : 'The party ended.' ;
@@ -52,16 +59,28 @@
toast ( ` 🎉 ${ why } ` , { duration : 5000 } ) ;
return ;
}
setStatus ( 'Reconnecting…' ) ;
st . retry = setTimeout ( connect , 3 000) ;
const delay = Math . min ( 30000 , 1000 * 2 * * Math . min ( st . retries ++ , 5 ) ) + Math . random ( ) * 500 ;
setStatus ( ` Connection lost · retrying in ${ Math . ceil ( delay / 1 000) } s ` ) ;
st . retry = setTimeout ( connect , delay ) ;
} ;
ws . onerror = ( ) => { if ( ! st . code && isHost ( ) ) setStatus ( 'Could not reach the server' ) ; } ;
}
function onMessage ( m ) {
DirectMedia . attach ( 'party' , { send , peers : ( ) => st . members . filter ( m => m . pid !== pid ) . map ( m => ( { id : m . pid , name : m . name , files : m . host && st . state ? . directAvailable && st . state ? . v ? [ st . state . v . id ] : [ ] } ) ) } ) ;
if ( m . type === 'direct' ) { DirectMedia . handle ( 'party' , m ) ; return ; }
if ( m . type === 'pong' ) {
if ( m . echo !== st . pingAt ) return ;
const rtt = Date . now ( ) - st . pingAt ;
st . lastPong = Date . now ( ) ;
if ( rtt < 5000 ) st . offset = m . now - ( st . pingAt + rtt / 2 ) ;
const el = $ ( 'ptLatency' ) ; if ( el ) el . textContent = ` ${ Math . round ( rtt ) } ms round trip ` ;
return ;
}
if ( m . type === 'hello' ) {
DirectMedia . attach ( 'party' , { send , peers : ( ) => st . members . filter ( m => m . pid !== pid ) . map ( m => ( { id : m . pid , name : m . name , files : m . host && st . state ? . directAvailable && st . state ? . v ? [ st . state . v . id ] : [ ] } ) ) } ) ;
st . retries = 0 ; st . lastPong = Date . now ( ) ; st . lastPush = '' ;
st . state = m . state || null ;
st . code = m . code ;
if ( m . secret ) { st . secret = m . secret ; ls . set ( HOST _KEY , JSON . stringify ( { code : m . code , secret : m . secret } ) ) ; }
st . offset = ( m . now || Date . now ( ) ) - Date . now ( ) ;
@@ -70,7 +89,7 @@
st . dj = m . dj || { enabled : false , entries : [ ] } ; st . djPending = false ;
st . allowControl = ! ! m . allowControl ;
st . present = m . present || null ; renderPresent ( ) ;
setStatus ( isHost ( ) ? 'You’ re hosting' : 'Synced with the host') ;
setStatus ( isHost ( ) ? 'You’ re hosting' : st . detached ? 'Watching independently' : 'Following the host') ;
if ( m . lost && isHost ( ) ) toast ( ` 🎉 The old party was lost (server restart). New code ${ m . code } — share the link again ` , { duration : 8000 } ) ;
render ( ) ;
if ( isHost ( ) ) {
@@ -96,6 +115,7 @@
if ( st . role === 'guest' ) applyState ( m . state ) ;
} else if ( m . type === 'members' ) {
st . members = m . members || [ ] ;
if ( m . hostAway ) setStatus ( 'Host disconnected · waiting for them to return' ) ;
if ( m . joined ) sysMsg ( ` ${ m . joined } joined ` ) ;
if ( m . left ) sysMsg ( ` ${ m . left } left ${ m . hostAway ? ' — waiting for the host to come back' : '' } ` ) ;
render ( ) ;
@@ -137,12 +157,12 @@
} ;
}
function pushState ( force = false ) {
if ( ! isHost ( ) || ! st . ws || st . restoring ) return ;
if ( ! isHost ( ) || st . ws ? . readyState !== 1 || st . restoring ) return ;
const s = stateNow ( ) ;
const key = JSON . stringify ( { ... s , cur : Math . round ( s . cur ) } ) ;
if ( ! force && key === st . lastPush ) return ;
st . lastPush = key ;
send ( { type : 'state' , state : s } ) ;
if ( send ( { type : 'state' , state : s } ) ) st . lastPush = key ;
updateNowPlaying ( s . v ) ;
}
function runCommand ( m ) {
switch ( m . cmd ) {
@@ -162,25 +182,37 @@
const quiet = ( ms = 1500 ) => { st . quietUntil = Date . now ( ) + ms ; } ;
function targetTime ( s ) {
if ( ! s ) return 0 ;
return s . paused ? s . cur : s . cur + ( ( serverNow ( ) - ( s . ts || serverNow ( ) ) ) / 1000 ) * ( s . rate || 1 ) ;
return Math . max ( 0 , s . paused ? s . cur : s . cur + Math . max ( 0 , ( serverNow ( ) - ( s . ts || serverNow ( ) ) ) / 1000 ) * ( s . rate || 1 ) ) ;
}
async function applyState ( s ) {
st . state = s ;
updateNowPlaying ( s ? . v ) ;
if ( st . role !== 'guest' || st . detached || ! s || ! s . v ) { render ( ) ; return ; }
if ( ! st . members . some ( m => m . host ) ) return ;
if ( st . loadingId ) return ; // a load is running; its finish re-applies the newest state
const target = targetTime ( s ) ;
if ( ! current || ! current . meta || current . meta . id !== s . v . id ) {
const session = st . session ;
st . loadingId = s . v . id ;
quiet ( 30000 ) ;
try { await playVideoAt ( { ... s . v } , target ) ; } catch { /* the drift timer retries */ }
finally { st . loadingId = '' ; quiet ( ) ; }
finally { if ( session === st . session ) { st . loadingId = '' ; quiet ( ) ; } }
if ( session !== st . session ) return ;
if ( st . state && st . state . v && current && current . meta && current . meta . id === st . state . v . id ) applyState ( st . state ) ;
return ;
}
const M = Player . master ;
if ( ! M ) return ;
if ( ( M . playbackRate || 1 ) !== ( s . rate || 1 ) ) { els . speed . value = String ( s . rate || 1 ) ; Player . applySpeed ( ) ; }
if ( Math . abs ( ( M . currentTime || 0 ) - target ) > 1.5 ) { quiet ( ) ; Player . seek ( target ) ; }
// Do not keep discarding buffered data while a slow device/network catches up.
const drift = Math . abs ( ( M . currentTime || 0 ) - target ) ;
const threshold = st . gentle && ! s . paused ? 4 : 1.5 ;
if ( drift > threshold && ! M . seeking && ( s . paused || M . readyState >= 3 ) &&
Date . now ( ) - st . lastSeekAt > ( st . gentle ? 8000 : 2500 ) ) {
st . lastSeekAt = Date . now ( ) ; quiet ( ) ; Player . seek ( Math . min ( target , Number . isFinite ( M . duration ) ? M . duration : target ) ) ;
}
if ( ! st . ws || st . ws . readyState !== 1 ) return ;
setStatus ( M . readyState < 3 && ! s . paused ? 'Buffering · keeping your place' : 'Following the host' ) ;
if ( s . paused && ! M . paused ) { quiet ( ) ; Player . pause ( ) ; updatePlayBtn ( ) ; }
else if ( ! s . paused && M . paused ) { quiet ( ) ; Player . play ( ) ; updatePlayBtn ( ) ; }
}
@@ -208,8 +240,8 @@
return true ;
}
function resync ( ) {
st . detached = false ;
if ( ! st . ws ) { setStatus ( 'Reconnecting…' ) ; connect ( ) ; render ( ) ; return ; }
st . detached = false ; st . replaced = false ; st . lastSeekAt = 0 ;
if ( ! st . ws || st . ws . readyState !== 1 ) { setStatus ( 'Reconnecting…' ) ; connect ( ) ; render ( ) ; return ; }
setStatus ( 'Synced with the host' ) ;
render ( ) ;
applyState ( st . state ) ;
@@ -304,6 +336,8 @@
// ---------- lifecycle ----------
function start ( ) {
st . tab = 'chat' ;
st . session ++ ; st . replaced = false ; st . detached = false ; st . loadingId = '' ; st . state = null ;
st . role = 'host' ;
st . code = '' ; st . secret = '' ; st . chat = [ ] ; st . members = [ ] ;
ls . set ( HOST _KEY , null ) ;
@@ -315,7 +349,10 @@
function join ( code , name ) {
code = String ( code || '' ) . toUpperCase ( ) . replace ( /[^A-Z0-9]/g , '' ) ;
if ( code . length !== 6 ) { toast ( 'Party codes have 6 letters/numbers' ) ; return ; }
if ( inParty ( ) ) leave ( false ) ;
if ( name ) { st . name = name ; ls . set ( NAME _KEY , name ) ; }
st . session ++ ; st . replaced = false ; st . loadingId = '' ; st . state = null ; st . lastSeekAt = 0 ;
st . tab = 'chat' ;
st . role = 'guest' ; st . inviteCode = '' ;
st . code = code ; st . detached = false ; st . chat = [ ] ; st . members = [ ] ;
showPanel ( ) ;
@@ -325,20 +362,28 @@
}
function beginTimers ( ) {
clearInterval ( st . pushTimer ) ; clearInterval ( st . driftTimer ) ;
st . pushTimer = setInterval ( ( ) => pushState ( ) , 3000 ) ;
st . driftTimer = setInterval ( ( ) => { if ( st . role === 'guest' && st . state && ! st . detached ) applyState ( st . state ) ; } , 2500 ) ;
st . pushTimer = setInterval ( ( ) => {
pushState ( ) ;
if ( st . ws ? . readyState === 1 && Date . now ( ) - st . pingAt >= 15000 ) {
if ( st . lastPong && Date . now ( ) - st . lastPong > 45000 ) { setStatus ( 'Reconnecting…' ) ; connect ( ) ; return ; }
st . pingAt = Date . now ( ) ; send ( { type : 'ping' , echo : st . pingAt } ) ;
}
} , 3000 ) ;
st . driftTimer = setInterval ( ( ) => { if ( st . role === 'guest' && st . ws ? . readyState === 1 && st . state && ! st . detached ) applyState ( st . state ) ; } , 2500 ) ;
}
function leave ( sendEnd = true ) {
if ( isHost ( ) && sendEnd ) send ( { type : 'end' } ) ;
if ( st . voice . on ) toggleVoice ( ) ;
const ws = st . ws ;
st . ws = null ;
st . session ++ ; st . loadingId = '' ; st . state = null ; st . retries = 0 ;
st . role = null ; st . code = '' ; st . secret = '' ;
clearTimeout ( st . retry ) ; clearInterval ( st . pushTimer ) ; clearInterval ( st . driftTimer ) ;
try { if ( ws ) ws . close ( ) ; } catch { /* gone */ }
ls . set ( HOST _KEY , null ) ;
st . present = null ; renderPresent ( ) ;
$ ( 'partyPanel' ) . classList . add ( 'hidden' ) ;
$ ( 'partyPanel' ) . querySelector ( '.pt-body' ) . dataset . view = '' ;
render ( ) ;
}
@@ -350,82 +395,135 @@
function renderLyricsToggle ( ) {
const b = document . querySelector ( '#partyPanel .pt-lyrics' ) ;
if ( ! b ) return ;
b . textContent = lyricsOn ( ) ? '📺 Show the video' : '🎤 Lyrics instead of video' ;
b . textContent = lyricsOn ( ) ? 'Show video' : 'Lyrics instead of video' ;
b . classList . toggle ( 'on' , lyricsOn ( ) ) ;
const l = document . querySelector ( '#partyPanel .pt-land' ) ;
if ( l ) { l . hidden = ! lyricsOn ( ) ; l . textContent = Notes . stageLyrics . landscapeOn ? . ( ) ? '↺ Exit landscape' : '↻ Landscape lyrics' ; }
}
function setStatus ( t ) { const el = $ ( 'ptStatus' ) ; if ( el ) el . textContent = t ; }
function sysMsg ( text ) { st . chat . push ( { sys : true , text , at : Date . now ( ) } ) ; renderChat ( ) ; }
function setStatus ( t ) { const el = $ ( 'ptStatus' ) ; if ( el && el . textContent !== t ) el . textContent = t ; }
function sysMsg ( text ) { st . chat . push ( { sys : true , text , at : Date . now ( ) } ) ; if ( st . chat . length > 100 ) st . chat . shift ( ) ; renderChat ( ) ; }
function link ( ) { return ` ${ location . origin } /?party= ${ st . code } ` ; }
function showPanel ( ) { Lazy . load ( 'feature:party-dj' ) . then ( render ) . catch ( error => toast ( error . message ) ) ; Lazy . load ( 'feature: direct' ) . catch ( error => toast ( error . message ) ) ; $ ( 'partyPanel' ) . classList . remove ( 'hidden' ) ; st . minimized = false ; $ ( 'partyPanel' ) . classList . remove ( 'min' ) ; render ( ) ; }
function showPanel ( ) { Lazy . load ( 'feature:direct' ) . catch ( error => toast ( error . message ) ) ; $ ( 'partyPanel' ) . classList . remove ( 'hidden' ) ; st . minimized = false ; $ ( 'partyPanel' ) . classList . remove ( 'min' ) ; render ( ) ; }
function updateNowPlaying ( video ) {
const el = $ ( 'ptNow' ) ;
const title = video ? . title || 'Choose a video to watch together' ;
if ( el && el . textContent !== title ) el . textContent = title ;
const play = document . querySelector ( '#partyPanel [data-act="playback"]' ) ;
if ( play ) { const label = Player . master ? . paused ? 'Play' : 'Pause' ; if ( play . textContent !== label ) play . textContent = label ; play . disabled = ! current ? . meta ; }
}
function updateSection ( selector , key , html ) {
const el = document . querySelector ( ` #partyPanel ${ selector } ` ) ;
if ( ! el || el . _key === key ) return ;
el . _key = key ; el . innerHTML = html ;
}
function selectTab ( tab ) {
st . tab = tab ;
const panel = $ ( 'partyPanel' ) ;
panel . querySelectorAll ( '[data-tab]' ) . forEach ( b => {
b . setAttribute ( 'aria-selected' , String ( b . dataset . tab === tab ) ) ;
b . tabIndex = b . dataset . tab === tab ? 0 : - 1 ;
} ) ;
panel . querySelectorAll ( '[data-pane]' ) . forEach ( p => { p . hidden = p . dataset . pane !== tab ; } ) ;
if ( tab === 'chat' ) renderChat ( ) ;
if ( tab === 'queue' ) Lazy . load ( 'feature:party-dj' ) . then ( render ) . catch ( error => toast ( error . message ) ) ;
}
function render ( ) {
const panel = $ ( 'partyPanel' ) ;
const chip = $ ( 'navPartyBtn' ) ;
if ( chip ) {
const lab el = chip . querySelector ( '.nav-label' ) ;
if ( label ) label . textContent = inParty ( ) ? ` Party · ${ st . members . length } ` : 'Watch party' ;
else chip . textContent = inParty ( ) ? ` 🎉 Party · ${ st . members . length } ` : '🎉 Watch party' ;
}
if ( ! panel || panel . classList . contains ( 'hidden' ) ) return ;
const nameOk = ! ! myName ( ) ;
// Keep what the user is typing across re-renders (members/DJ updates rebuild the panel).
const draft = { msg : panel . querySelector ( '.pt-msg' ) ? . value || '' , focus : document . activeElement ? . classList ? . contains ( 'pt-msg' ) ,
name : panel . querySelector ( '.pt-name' ) ? . value || '' , code : panel . querySelector ( '.pt-code' ) ? . value || st . inviteCode } ;
const label = $ ( 'navPartyBtn' ) ? . querySelector ( '.nav-label' ) ;
if ( label ) label . textContent = inParty ( ) ? ` Party · ${ st . members . length } ` : 'Watch party' ;
if ( ! pan el || panel . classList . contains ( 'hidden' ) || st . minimized ) return ;
const body = panel . querySelector ( '.pt-body' ) ;
if ( ! inParty ( ) ) {
panel . querySelector ( '. pt-body ' ) . innerHTML = `
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
${ nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>' }
$ ( 'ptTitle ' ) . textContent = 'Watch party' ;
if ( body . dataset . view === 'lobby' ) return ;
body . dataset . view = 'lobby' ;
body . innerHTML = ` <div class="pt-lobby"><h2>Good company.<br>One shared screen.</h2>
<p>Watch in sync, talk in chat, and choose what plays next.</p>
${ myName ( ) ? '' : '<label class="pt-field">Your name<input class="pt-name" maxlength="40" autocomplete="nickname" placeholder="Your name" /></label>' }
<button class="rv-primary" type="button" data-act="start">Start a party</button>
<div class="pt-or">or join one </div>
<div class="pt-row"><input class="pt-code" maxlength="7 " placeholder="CODE " autocapitalize="characters" value=" ${ escapeHtml ( draf t. c ode) } " /><button class="np-btn" type="button" data-act="join">Join</button></div>` ;
const nameIn = panel . querySelector ( '.pt-name' ) ; if ( nameIn ) nameIn . value = draft . name ;
$ ( 'ptTitle' ) . textContent = '🎉 Watch party' ;
setStatus ( '' ) ;
<div class="pt-or">Have an invitati on? </div>
<label class="pt-field" for="ptCode">Party code</label>< div class="pt-row"><input id="ptCode" class="pt-code" maxlength="6 " placeholder="ABC123 " autocapitalize="characters" autocomplete="off" value=" ${ escapeHtml ( s t. inviteC ode) } " /><button class="np-btn" type="button" data-act="join">Join</button></div>
<p class="pt-hint">Each person streams on their own device. Audio-only is available for slower connections.</p></div> ` ;
return ;
}
$ ( 'ptTitle' ) . textContent = ` 🎉 Party ${ st . code || '…' } ` ;
const s = st . state && st . state . v ;
panel . querySelector ( '.pt-body' ) . innerHTML = `
${ st . code ? ` <div class="pt-share"><code> ${ escapeHtml ( link ( ) ) } </code ><button class="np-btn" type="button" data-act="copy">Copy </button> ${ navigator . share ? '<button class="np-btn" type="button" data-act="s hare">↗ </button>' : '' } </div>` : '' }
${ ! isHost ( ) && s ? ` <div class="pt-now"> ${ TransportIcons . svg ( 'play' ) } ${ escapeHtml ( s . title ) } </div> ` : '' }
${ ! isHost ( ) && st . detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the hos t</button>' : '' }
<div class="pt-members"> ${ st . members . map ( ( m ) => ` <span class="pt-member ${ m . pid === pid ? ' me' : '' } "> ${ m . host ? '👑 ' : '' } ${ escapeHtml ( m . name ) } ${ m . vo ice ? ' 🎤' : '' } </spa n> ` ) . join ( '' ) } </div>
<div class="pt-present"> ${ st . present
? ` ${ st . presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : '' } ${ canPresent ( ) ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : '' } `
: canPresent ( ) ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : '' } </div >
${ isHost ( ) ? ` <label class="pt-toggle "><input type="checkbox" data-act="allow" ${ st . allowControl ? 'checked' : '' } /> Guests can control playback</label> ` : '' }
<div class="pt-present"><button class="np-btn pt-lyrics ${ lyricsOn ( ) ? ' on' : '' } " type="button" data-act="lyrics"> ${ lyricsOn ( ) ? '📺 Show the video' : '🎤 Lyrics instead of video' } </butt on>
<button class="np-btn pt-land" type="button" data-act="land" ${ lyricsOn ( ) ? '' : ' hidden' } > ${ Notes . stageLyrics . landscapeOn ? . ( ) ? '↺ Exit landscape' : '↻ Landscape lyrics' } </button></div >
<div class="pt-voice" >
<button class="np-btn ${ st . voice . on ? ' on' : '' } " type="button" data-act="voice"> ${ st . voice . on ? '🎤 Leave voice' : '🎤 Join voice' } </button >
${ st . voice . on ? ` <button class="np-btn" type="button" data-act="mute"> ${ st . voice . muted ? '🔇 Unmute' : '🔈 Mute' } </button> ` : '' }
<span class="pt-voice-n"> ${ st . voice . on ? ` ${ st . voice . pcs . size } connected ` : '' } </span >
</div >
<div class="pt-chat" id="ptCha t"></div>
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send </button></form >
<button class="pt-leave" type="button" data-act="leave"> ${ isHost ( ) ? 'End the party' : 'Leave the party' } </button>` ;
const dj = document . createElement ( 'div' ) ; panel . querySelector ( '.pt-body' ) . append ( dj ) ;
if ( Lazy . loaded ( 'feature:party-dj' ) ) PartyDJ . render ( dj , { state : st . dj , host : isHost ( ) , pid , send , search : async q => { const result = await API . search ( q ) ; return result . results || result . videos || [ ] ; } } ) ;
const msgIn = panel . querySelector ( '.pt-msg' ) ;
if ( msgIn && draft . msg ) { msgIn . value = draf t. msg ; if ( draft . focus ) msgIn . focus ( ) ; }
renderChat ( ) ;
$ ( 'ptTitle' ) . textContent = isHost ( ) ? 'Your watch party' : 'Watch party' ;
if ( body . dataset . view !== 'room' ) {
body . dataset . view = 'room' ;
body . innerHTML = ` <div class="pt-session"><div><span class="pt-caption">Invitation code</span><strong id="ptRoomCode"></strong></div ><button class="np-btn" data-act="copy" type="button">Copy link </button> ${ navigator . share ? '<button class="np-btn" data-act="share" type="button">S hare</button>' : '' } </div>
<div class="pt-playing"><span class="pt-caption">Watching together</span><strong id="ptNow"></strong><button class="pt-resync np-btn" type="button" data-act="resync">Re-sync</button></div>
${ isHost ( ) ? ` <div class="pt-transport" aria-label="Host playback controls"><button class="np-btn" type="button" data-act="back10" aria-label="Back 10 seconds">− 10 s</button><button class="np-btn" type="button" data-act="playback">Play</button><button class="np-btn" type="button" data-act="forward10" aria-label="Forward 10 seconds">+10 s</button><button class="np-btn" type="button" data-act="next">Nex t</button></div> ` : '' }
<div class="pt-tabs" role="tablist" aria-label="Party sections"> ${ [ 'chat' , 'queue' , 'room' ] . map ( t => ` <button type="button" id="ptTab- ${ t } " role="tab" data-tab=" ${ t } " aria-controls="ptPane- ${ t } "> ${ t [ 0 ] . toUpperCase ( ) + t . sl ice( 1 ) } </butto n> ` ) . join ( '' ) } </div>
<section id="ptPane-chat" class="pt-pane" data-pane="chat" role="tabpanel" aria-labelledby="ptTab-chat">
<div class="pt-chat" id="ptChat" role="log" aria-label="Party chat" aria-live="polite" aria-relevant="additions" tabindex="0"></div>
<button type="button" class="pt-new np-btn" data-act="latest" hidden>New messages · jump to latest</button >
<form class="pt-send "><input class="pt-msg" aria-label="Message the party" maxlength="500" placeholder="Say something…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
</secti on>
<section id="ptPane-queue" class="pt-pane" data-pane="queue" role="tabpanel" aria-labelledby="ptTab-queue" hidden><p class="pt-hint">Suggest a video and vote for what’ s next. The host approves the queue.</p><div class="pt-dj"></div></section >
<section id="ptPane-room" class="pt-pane" data-pane="room" role="tabpanel" aria-labelledby="ptTab-room" hidden >
<h3>In the room <span id="ptMemberCount"></span></h3><div class="pt-members"></div >
<div class="pt-options"><h3>On this device</h3>
<label class="pt-toggle"><input type="checkbox" data-act="gentle" />Gentle sync</label><p class="pt-hint">Allows a small delay and fewer seeks when your connection struggles.</p >
<label class="pt-toggle"><input type="checkbox" data-act="audio" />Audio only</label><p class="pt-hint">Use the audio stream to reduce video data and decoding.</p><span id="ptLatency" class="pt-hint">Measuring connection…</span >
</div> <div class="pt-voice"></div><div class="pt-present"></div><div class="pt-hos t"></div>
<div class="pt-lyrics-controls"><button class="np-btn pt-lyrics" type="button" data-act="lyrics">Lyrics instead of video</button><button class="np-btn pt-land" type="button" data-act="land" hidden>Landscape lyrics </button></div >
<button class="pt-leave" type="button" data-act="leave"> ${ isHost ( ) ? 'End the party' : 'Leave the party' } </button>
</section> ` ;
selectTab ( st . tab ) ;
}
$ ( 'ptRoomCode' ) . textContent = s t. code || 'Connecting…' ;
panel . querySelector ( '[data-act="copy"]' ) . disabled = ! st . code ;
panel . querySelector ( '.pt-resync' ) . hidden = isHost ( ) ;
updateNowPlaying ( isHost ( ) ? current ? . meta : st . state ? . v ) ;
$ ( 'ptMemberCount' ) . textContent = ` ( ${ st . members . length } ) ` ;
updateSection ( '.pt-members' , JSON . stringify ( st . members ) , st . members . map ( m => ` <span class="pt-member ${ m . pid === pid ? ' me' : '' } "> ${ escapeHtml ( m . name ) } ${ m . host ? ' · Host' : '' } ${ m . voice ? ' · Voice' : '' } </span> ` ) . join ( '' ) ) ;
panel . querySelector ( '[data-act="gentle"]' ) . checked = st . gentle ;
panel . querySelector ( '[data-act="audio"]' ) . checked = ! ! data . settings . audioOnly ;
updateSection ( '.pt-voice' , ` ${ st . voice . on } / ${ st . voice . muted } / ${ st . voice . pcs . size } ` , ` <button class="np-btn" type="button" data-act="voice"> ${ st . voice . on ? 'Leave voice' : 'Join voice' } </button> ${ st . voice . on ? ` <button class="np-btn" type="button" data-act="mute"> ${ st . voice . muted ? 'Unmute' : 'Mute' } </button><span class="pt-voice-n"> ${ st . voice . pcs . size } connected</span> ` : '<span class="pt-hint">Microphone is off</span>' } ` ) ;
updateSection ( '.pt-present' , ` ${ ! ! st . present } / ${ st . presentHidden } / ${ canPresent ( ) } ` , ` ${ st . present && st . presentHidden ? '<button class="np-btn" type="button" data-act="present-show">Show presentation</button>' : '' } ${ canPresent ( ) ? ` <button class="np-btn" type="button" data-act="present"> ${ st . present ? 'Change presentation' : 'Present a link' } </button> ${ st . present ? '<button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : '' } ` : '' } ` ) ;
const host = panel . querySelector ( '.pt-host' ) ;
if ( isHost ( ) && ! host . firstChild ) host . innerHTML = ` <h3>Host controls</h3><label class="pt-toggle"><input type="checkbox" data-act="allow" />Guests can control playback</label> ` ;
if ( isHost ( ) ) {
host . querySelector ( '[data-act="allow"]' ) . checked = st . allowControl ;
}
const dj = panel . querySelector ( '.pt-dj' ) ;
const djKey = JSON . stringify ( st . dj ) ;
if ( Lazy . loaded ( 'feature:party-dj' ) && dj . _key !== djKey ) {
dj . _key = djKey ;
PartyDJ . render ( dj , { state : st . dj , host : isHost ( ) , pid , send , search : async q => { const result = await API . search ( q ) ; return result . results || result . videos || [ ] ; } } ) ;
if ( ! st . dj . enabled ) dj . insertAdjacentHTML ( 'beforeend' , ` <p class="pt-hint"> ${ isHost ( ) ? 'Enable Party DJ to let everyone suggest and vote.' : 'The host hasn’ t enabled suggestions yet.' } </p> ` ) ;
}
renderLyricsToggle ( ) ; renderChat ( ) ;
}
function renderChat ( ) {
const box = $ ( 'ptChat' ) ;
if ( ! box ) return ;
box . innerHTML = st . chat . map ( ( c ) => c . sys
? ` <div class="pt-sys"> ${ escapeHtml ( c . text ) } </div> `
: ` <div class="pt-line ${ c . pid === pid ? ' me' : '' } "><b> ${ escapeHtml ( c . from ) } </b> ${ escapeHtml ( c . text ) } </div> ` ) . join ( '' ) ;
box . scrollTop = box . scrollHeight ;
if ( ! box || st . minimized || st . tab !== 'chat' || $ ( 'partyPanel' ) . classList . contains ( 'hidden' ) ) return ;
const nearBottom = box . scrollHeight - box . scrollTop - box . clientHeight < 48 ;
const previous = box . _messages || [ ] ;
if ( box . _messages && previous . length === st . chat . length && previous . every ( ( c , i ) => c === st . chat [ i ] ) ) return ;
const appendOnly = previous . length < = st . chat . length && previous . every ( ( c , i ) => c === st . chat [ i ] ) ;
const scroll = box . scrollTop ;
if ( ! appendOnly || ! previous . length ) box . replaceChildren ( ) ;
const fragment = document . createDocumentFragment ( ) ;
for ( const c of st . chat . slice ( appendOnly ? previous . length : 0 ) ) {
const row = document . createElement ( 'div' ) ;
row . className = c . sys ? 'pt-sys' : ` pt-line ${ c . pid === pid ? ' me' : '' } ` ;
if ( c . sys ) row . textContent = c . text ;
else { const name = document . createElement ( 'b' ) ; name . textContent = c . from ; const message = document . createElement ( 'span' ) ; message . textContent = c . text ; row . append ( name , message ) ; }
fragment . append ( row ) ;
}
box . append ( fragment ) ; box . _messages = st . chat . slice ( ) ;
if ( ! st . chat . length ) box . innerHTML = '<p class="pt-empty">You’ re in good company.<br>Start the conversation.</p>' ;
const latest = document . querySelector ( '#partyPanel .pt-new' ) ;
if ( nearBottom || ! previous . length ) { box . scrollTop = box . scrollHeight ; latest . hidden = true ; }
else { box . scrollTop = scroll ; latest . hidden = false ; }
}
function open ( ) {
const panel = $ ( 'partyPanel' ) ;
if ( typeof closeSidebar === 'function' ) { try { closeSidebar ( ) ; } catch { /* not open */ } }
if ( panel . classList . contains ( 'hidden' ) ) showPanel ( ) ;
else if ( st . minimized ) { st . minimized = false ; panel . classList . remove ( 'min' ) ; }
else if ( st . minimized ) { st . minimized = false ; panel . classList . remove ( 'min' ) ; render ( ) ; }
else if ( ! inParty ( ) ) panel . classList . add ( 'hidden' ) ;
else { st . minimized = true ; panel . classList . add ( 'min' ) ; }
}
@@ -435,10 +533,19 @@
$ ( 'ptMin' ) . addEventListener ( 'click' , ( ) => {
if ( ! inParty ( ) ) { panel . classList . add ( 'hidden' ) ; return ; }
st . minimized = ! st . minimized ;
panel . classList . toggle ( 'min' , st . minimized ) ;
panel . classList . toggle ( 'min' , st . minimized ) ; if ( ! st . minimized ) render ( ) ;
} ) ;
panel . addEventListener ( 'keydown' , ( e ) => {
e . stopPropagation ( ) ;
if ( e . target . matches ( '[data-tab]' ) && [ 'ArrowLeft' , 'ArrowRight' , 'Home' , 'End' ] . includes ( e . key ) ) {
e . preventDefault ( ) ; const tabs = [ 'chat' , 'queue' , 'room' ] ;
const i = e . key === 'Home' ? 0 : e . key === 'End' ? 2 : ( tabs . indexOf ( st . tab ) + ( e . key === 'ArrowRight' ? 1 : 2 ) ) % 3 ;
selectTab ( tabs [ i ] ) ; panel . querySelector ( ` [data-tab=" ${ tabs [ i ] } "] ` ) . focus ( ) ;
}
if ( e . key === 'Enter' && e . target . matches ( '.pt-code' ) ) { e . preventDefault ( ) ; panel . querySelector ( '[data-act="join"]' ) . click ( ) ; }
} ) ;
panel . addEventListener ( 'keydown' , ( e ) => e . stopPropagation ( ) ) ;
panel . addEventListener ( 'click' , async ( e ) => {
const tab = e . target . closest ( '[data-tab]' ) ; if ( tab ) { selectTab ( tab . dataset . tab ) ; return ; }
const a = e . target . closest ( '[data-act]' ) ;
if ( ! a || a . tagName === 'INPUT' ) return ;
const act = a . dataset . act ;
@@ -446,7 +553,14 @@
const name = nameIn ? nameIn . value . trim ( ) : '' ;
if ( ( act === 'start' || act === 'join' ) && ! myName ( ) && ! name ) { toast ( 'Enter your name first' ) ; if ( nameIn ) nameIn . focus ( ) ; return ; }
if ( name ) { st . name = name ; ls . set ( NAME _KEY , name ) ; }
if ( act === 'start' ) start ( ) ;
if ( act === 'latest' ) { const box = $ ( 'ptChat' ) ; box . scrollTop = box . scrollHeight ; a . hidden = true ; }
else if ( isHost ( ) && [ 'back10' , 'forward10' , 'playback' , 'next' ] . includes ( act ) ) {
if ( act === 'playback' ) Player . toggle ( ) ;
else if ( act === 'next' ) playNext ( ) ;
else Player . seek ( Math . max ( 0 , ( Player . master ? . currentTime || 0 ) + ( act === 'back10' ? - 10 : 10 ) ) ) ;
pushState ( true ) ;
}
else if ( act === 'start' ) start ( ) ;
else if ( act === 'join' ) join ( panel . querySelector ( '.pt-code' ) . value ) ;
else if ( act === 'copy' ) { try { await navigator . clipboard . writeText ( link ( ) ) ; toast ( 'Party link copied ✓' ) ; } catch { toast ( 'Copy blocked' ) ; } }
else if ( act === 'share' ) { try { await navigator . share ( { title : 'Join my watch party' , url : link ( ) } ) ; } catch { /* dismissed */ } }
@@ -467,13 +581,18 @@
else if ( act === 'present-show' ) { st . presentHidden = false ; renderPresent ( ) ; render ( ) ; }
} ) ;
panel . addEventListener ( 'change' , ( e ) => {
if ( e . target . dataset . act === 'gentle' ) { st . gentle = e . target . checked ; ls . set ( 'ytpPartyGentle' , String ( st . gentle ) ) ; }
if ( e . target . dataset . act === 'audio' ) {
quiet ( 10000 ) ; els . audioOnlyToggle . checked = e . target . checked ;
els . audioOnlyToggle . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
}
if ( e . target . dataset . act === 'allow' ) { st . allowControl = e . target . checked ; send ( { type : 'settings' , allowControl : st . allowControl } ) ; }
} ) ;
panel . addEventListener ( 'submit' , ( e ) => {
e . preventDefault ( ) ;
const input = panel . querySelector ( '.pt-msg' ) ;
const text = input . value . trim ( ) ;
if ( text ) { send ( { type : 'chat' , text } ) ; input . value = '' ; }
if ( text ) { if ( send ( { type : 'chat' , text } ) ) input . value = '' ; else toast ( 'Not connected yet. Your message is still here.' ) ; }
} ) ;
// Host: push on every player change; guests: detect their own actions.
[ 'play' , 'pause' , 'seeked' , 'ratechange' , 'loadedmetadata' ] . forEach ( ( ev ) => {
@@ -558,12 +677,14 @@
function boot ( ) {
bindUi ( ) ;
addEventListener ( 'online' , ( ) => { if ( inParty ( ) && ! st . replaced ) connect ( ) ; } ) ;
addEventListener ( 'offline' , ( ) => { if ( inParty ( ) ) setStatus ( 'Offline · waiting for your connection' ) ; } ) ;
render ( ) ;
// iOS keeps a dead socket "open" after the PWA was in the background:
// after a long absence reconnect straight away instead of waiting for onclose.
document . addEventListener ( 'visibilitychange' , ( ) => {
if ( document . visibilityState === 'hidden' ) { st . hiddenAt = Date . now ( ) ; return ; }
if ( ! inParty ( ) || st . det ach ed) return ;
if ( ! inParty ( ) || st . repl aced) return ;
const ws = st . ws ;
if ( ! ws || ws . readyState !== 1 || Date . now ( ) - st . hiddenAt > 20000 ) {
st . ws = null ; try { if ( ws ) ws . close ( ) ; } catch { /* gone */ }