SYNC / static /js /room.js
triflix's picture
Update static/js/room.js
8531988 verified
// static/js/room.js
(function(){
// Read data from DOM (Jinja2 injected)
const app = document.getElementById('app');
const ROOM = app.dataset.room;
const NAME = app.dataset.name || 'Guest';
const SESSION = app.dataset.session;
const IS_ADMIN = app.dataset.isAdmin === 'true';
const ADMIN_TOKEN = app.dataset.adminToken || '';
// UI refs
const tabNow = document.getElementById('tab-now');
const tabPlaylist = document.getElementById('tab-playlist');
const tabParticipants = document.getElementById('tab-participants');
const tabs = document.querySelectorAll('.tab');
const album = document.getElementById('album');
const albumImg = document.getElementById('album-img');
const trackTitle = document.getElementById('track-title');
const trackArtist = document.getElementById('track-artist');
const btnCopy = document.getElementById('btn-copy');
const btnPlay = document.getElementById('btn-play');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const seek = document.getElementById('seek');
const timeCur = document.getElementById('time-cur');
const timeTotal = document.getElementById('time-total');
const playlistList = document.getElementById('playlist-list');
const participantsList = document.getElementById('participants-list');
const btnUpload = document.getElementById('btn-upload');
const uploadModal = document.getElementById('upload-modal');
const uploadForm = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const uploaderNameInput = document.getElementById('uploader-name');
const uploadStatus = document.getElementById('upload-status');
const uploadCancel = document.getElementById('upload-cancel');
const btnEnableAudio = document.getElementById('btn-enable-audio');
// state
let socket = null;
let roomState = { tracks: [], playlist: [], current_track_id: null, play_state: 'paused', play_position:0, play_started_at:null, seq:0, participants:[] };
let tracksById = {};
let audio = new Audio();
audio.preload = 'metadata';
let scheduledPlayTimer = null;
let localSeeking = false;
let lastServerTimeOffset = 0; // server_time - Date.now()/1000
// helpers
function toMMSS(s){
s = Math.max(0, Math.floor(s||0));
const m = Math.floor(s/60);
const sec = s%60;
return `${m}:${String(sec).padStart(2,'0')}`;
}
function switchTab(name){
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === name));
tabNow.style.display = name==='now' ? '' : 'none';
tabPlaylist.style.display = name==='playlist' ? '' : 'none';
tabParticipants.style.display = name==='participants' ? '' : 'none';
}
function copyRoom(){
const url = `${location.origin}/room/${ROOM}`;
if (navigator.share) {
navigator.share({ title: 'Join my sync room', text: `Room ${ROOM}`, url }).catch(()=>{});
} else if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(()=> {
btnCopy.textContent = 'Copied';
setTimeout(()=> btnCopy.textContent = 'Share', 1500);
}).catch(()=>{});
} else {
alert(url);
}
}
// UI updates
function updatePlayButtonVisual(){
if (roomState.play_state === 'playing'){
btnPlay.innerHTML = '⏸'; // pause symbol
btnPlay.classList.add('playing');
btnPlay.classList.add('primary');
} else {
btnPlay.innerHTML = '▶'; // play symbol
btnPlay.classList.remove('playing');
btnPlay.classList.add('primary');
}
}
// render helpers
function renderPlaylist(){
playlistList.innerHTML = '';
const playlist = roomState.playlist || [];
playlist.forEach((tid, idx) => {
const t = tracksById[tid];
const row = document.createElement('div');
row.className = 'list-row' + (roomState.current_track_id === tid ? ' current' : '');
row.innerHTML = `
<div style="display:flex;gap:10px;align-items:center;">
<div style="width:36px;text-align:center;font-weight:600;color:var(--text-secondary)">${idx+1}</div>
<div>
<div style="font-weight:600">${t ? t.original_name : 'Track'}</div>
<div class="mini">by ${t ? t.uploader : 'unknown'}</div>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<div class="mini">${t && t.duration ? toMMSS(t.duration) : ''}</div>
${IS_ADMIN ? `<button data-set="${tid}" class="btn outline small">Set</button>` : ''}
</div>
`;
playlistList.appendChild(row);
if (IS_ADMIN) {
const btn = row.querySelector('button[data-set]');
btn.addEventListener('click', () => {
wsSend({ type: 'set_track', track_id: tid });
});
} else {
// allow tapping a track as non-admin to request_set (local only)
row.addEventListener('click', ()=> {
// request current state
wsSend({ type: 'request_sync' });
});
}
});
}
function renderParticipants(){
participantsList.innerHTML = '';
(roomState.participants || []).forEach(name => {
const el = document.createElement('div');
el.className = 'card mini';
el.textContent = name;
participantsList.appendChild(el);
});
}
// audio control logic - play schedule
function cancelScheduledPlay(){
if (scheduledPlayTimer) { clearTimeout(scheduledPlayTimer); scheduledPlayTimer = null; }
}
function schedulePlay(play_at, position){
cancelScheduledPlay();
const now = Date.now()/1000 + lastServerTimeOffset;
let delay = (play_at - now);
if (delay < 0) delay = 0;
// set position immediately
audio.currentTime = Math.max(0, position || 0);
// try to play after delay
scheduledPlayTimer = setTimeout(async () => {
try { await audio.play(); album.classList.add('playing'); }
catch (e) { console.warn('autoplay blocked', e); btnEnableAudio.style.display='inline-block'; }
scheduledPlayTimer = null;
}, delay*1000);
}
function setTrackById(track_id, autoPlay=false){
const t = tracksById[track_id];
if (!t) {
trackTitle.textContent = 'No track selected';
trackArtist.textContent = '';
audio.src = '';
return;
}
trackTitle.textContent = t.original_name;
trackArtist.textContent = t.uploader || '';
albumImg.src = t.url || 'https://cdn.dribbble.com/userupload/23374753/file/original-090a6e986e1faa15b0929d0bd3960318.gif';
audio.src = t.url;
// try set currentTime when metadata loaded
audio.addEventListener('loadedmetadata', function onmeta(){
audio.currentTime = Math.min(audio.duration || 0, roomState.play_position || 0);
timeTotal.textContent = toMMSS(audio.duration || 0);
audio.removeEventListener('loadedmetadata', onmeta);
});
if (!autoPlay) {
try { audio.pause(); } catch(e){}
album.classList.remove('playing');
}
updatePlayButtonVisual();
}
function handlePlayMsg(payload){
// payload: { play_at, position, track_id }
if (payload.track_id) roomState.current_track_id = payload.track_id;
setTrackById(roomState.current_track_id);
schedulePlay(payload.play_at, payload.position);
roomState.play_state = 'playing';
updatePlayButtonVisual();
}
function handlePauseMsg(payload){
cancelScheduledPlay();
audio.currentTime = payload.position || audio.currentTime;
audio.pause();
album.classList.remove('playing');
roomState.play_state = 'paused';
updatePlayButtonVisual();
}
function handleSeekMsg(payload){
const pos = payload.position || 0;
audio.currentTime = pos;
roomState.play_position = pos;
}
function handleSetTrack(payload){
roomState.current_track_id = payload.track_id;
roomState.play_state = 'paused';
roomState.play_position = 0;
setTrackById(payload.track_id, false);
renderPlaylist();
updatePlayButtonVisual();
}
function handleTrackAdded(payload){
const t = payload.track;
tracksById[t.track_id] = t;
if (!roomState.playlist.includes(t.track_id)) roomState.playlist.push(t.track_id);
renderPlaylist();
}
function applySyncState(state){
// build tracks map
tracksById = {};
(state.tracks || []).forEach(t => tracksById[t.track_id] = t);
roomState = Object.assign({}, roomState, state);
renderPlaylist();
renderParticipants();
if (state.current_track_id) setTrackById(state.current_track_id, state.play_state === 'playing');
// calculate server time offset
if (state.server_time) {
lastServerTimeOffset = state.server_time - (Date.now()/1000);
}
updatePlayButtonVisual();
}
// WS glue
function wsSend(obj){
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify(obj));
}
function connectWs(){
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const adminParam = ADMIN_TOKEN ? `&admin_token=${ADMIN_TOKEN}` : '';
const wsUrl = `${proto}://${location.host}/ws/${ROOM}?session_id=${SESSION}&name=${encodeURIComponent(NAME)}${adminParam}`;
socket = new WebSocket(wsUrl);
socket.addEventListener('open', ()=> {
console.log('ws open');
// initial ping to get timesync
wsSend({ type: 'ping', client_time: Date.now()/1000 });
// request a sync
wsSend({ type: 'request_sync' });
});
socket.addEventListener('message', (ev) => {
try {
const msg = JSON.parse(ev.data);
// handle core types
if (msg.type === 'joined') {
console.log('joined', msg);
} else if (msg.type === 'sync_state') {
applySyncState(msg);
} else if (msg.type === 'play') {
handlePlayMsg(msg);
} else if (msg.type === 'pause') {
handlePauseMsg(msg);
} else if (msg.type === 'seek') {
handleSeekMsg(msg);
} else if (msg.type === 'set_track') {
handleSetTrack(msg);
renderPlaylist();
} else if (msg.type === 'track_added') {
handleTrackAdded(msg);
} else if (msg.type === 'user_joined' || msg.type === 'user_left') {
// re-request full state to stay consistent
wsSend({ type: 'request_sync' });
} else if (msg.type === 'pong') {
// sync offset
if (msg.server_time && msg.client_time){
const now = Date.now()/1000;
const rtt = (now - msg.client_time);
const approxServerTime = msg.server_time + (rtt/2);
lastServerTimeOffset = approxServerTime - (Date.now()/1000);
}
}
} catch(e){
console.error('ws parse err', e);
}
});
socket.addEventListener('close', ()=> console.log('ws closed'));
socket.addEventListener('error', (e)=> console.error('ws error', e));
}
// bind UI events
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', ()=> switchTab(t.dataset.tab)));
btnCopy.addEventListener('click', copyRoom);
// Playback controls (admin toggles play/pause; non-admin requests sync)
btnPlay.addEventListener('click', async () => {
if (!IS_ADMIN){
wsSend({ type: 'request_sync' });
return;
}
if (roomState.play_state === 'playing'){
wsSend({ type: 'pause', position: audio.currentTime });
} else {
wsSend({ type: 'play', position: audio.currentTime });
}
});
btnPrev.addEventListener('click', ()=> {
if (!IS_ADMIN) return;
const idx = roomState.playlist.indexOf(roomState.current_track_id);
const prev = idx > 0 ? roomState.playlist[idx-1] : null;
if (prev) wsSend({ type: 'set_track', track_id: prev });
});
btnNext.addEventListener('click', ()=> {
if (!IS_ADMIN) return;
const idx = roomState.playlist.indexOf(roomState.current_track_id);
const next = (idx >= 0 && idx < roomState.playlist.length-1) ? roomState.playlist[idx+1] : null;
if (next) wsSend({ type: 'set_track', track_id: next });
});
// Seek slider
seek.addEventListener('input', (e) => {
localSeeking = true;
const v = parseFloat(e.target.value);
const dur = audio.duration || 0;
timeCur.textContent = toMMSS(v * dur);
});
seek.addEventListener('change', (e) => {
const v = parseFloat(e.target.value);
const dur = audio.duration || 0;
const pos = v * dur;
if (IS_ADMIN){
wsSend({ type: 'seek', position: pos });
} else {
audio.currentTime = pos;
}
localSeeking = false;
});
// audio progress update
audio.addEventListener('timeupdate', ()=> {
const dur = audio.duration || 0;
if (!localSeeking && dur > 0) {
seek.value = (audio.currentTime / dur);
timeCur.textContent = toMMSS(audio.currentTime);
timeTotal.textContent = toMMSS(dur);
}
});
audio.addEventListener('play', ()=> {
album.classList.add('playing');
roomState.play_state = 'playing';
updatePlayButtonVisual();
});
audio.addEventListener('pause', ()=> {
album.classList.remove('playing');
roomState.play_state = 'paused';
updatePlayButtonVisual();
});
audio.addEventListener('ended', ()=> {
album.classList.remove('playing');
roomState.play_state = 'paused';
updatePlayButtonVisual();
// If admin, advance to next track
if (IS_ADMIN) {
const idx = roomState.playlist.indexOf(roomState.current_track_id);
if (idx >= 0 && idx < roomState.playlist.length - 1) {
const next = roomState.playlist[idx+1];
wsSend({ type: 'set_track', track_id: next });
setTimeout(() => wsSend({ type: 'play', position: 0 }), 300);
}
}
});
// Upload modal
btnUpload.addEventListener('click', ()=> {
uploadModal.style.display = 'flex';
uploadModal.classList.add('fade-in');
uploadModal.setAttribute('aria-hidden','false');
});
uploadCancel.addEventListener('click', ()=> {
uploadModal.style.display = 'none';
uploadModal.setAttribute('aria-hidden','true');
});
uploadForm.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!fileInput.files || fileInput.files.length === 0) return;
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('room_code', ROOM);
fd.append('session_id', SESSION);
fd.append('uploader_name', uploaderNameInput.value || NAME);
uploadStatus.textContent = 'Uploading...';
try {
const res = await fetch('/upload', { method: 'POST', body: fd });
const data = await res.json();
if (data && data.ok) {
uploadStatus.textContent = 'Uploaded';
setTimeout(()=>{ uploadModal.style.display='none'; uploadModal.setAttribute('aria-hidden','true'); uploadStatus.textContent=''; fileInput.value=''; }, 700);
} else {
uploadStatus.textContent = 'Upload failed';
}
} catch (e) {
console.error(e);
uploadStatus.textContent = 'Upload error';
}
});
// enable audio button to unlock mobile autoplay
btnEnableAudio.addEventListener('click', async ()=> {
try { await audio.play(); audio.pause(); btnEnableAudio.style.display='none'; } catch(e){ console.warn(e); }
});
// copy link
btnCopy.addEventListener('click', copyRoom);
// start
connectWs();
})();