| |
| (function(){ |
| |
| 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 || ''; |
|
|
| |
| 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'); |
|
|
| |
| 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; |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| function updatePlayButtonVisual(){ |
| if (roomState.play_state === 'playing'){ |
| btnPlay.innerHTML = '⏸'; |
| btnPlay.classList.add('playing'); |
| btnPlay.classList.add('primary'); |
| } else { |
| btnPlay.innerHTML = '▶'; |
| btnPlay.classList.remove('playing'); |
| btnPlay.classList.add('primary'); |
| } |
| } |
|
|
| |
| 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 { |
| |
| row.addEventListener('click', ()=> { |
| |
| 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); |
| }); |
| } |
|
|
| |
| 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; |
| |
| audio.currentTime = Math.max(0, position || 0); |
| |
| 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; |
| |
| 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){ |
| |
| 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){ |
| |
| 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'); |
| |
| if (state.server_time) { |
| lastServerTimeOffset = state.server_time - (Date.now()/1000); |
| } |
| updatePlayButtonVisual(); |
| } |
|
|
| |
| 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'); |
| |
| wsSend({ type: 'ping', client_time: Date.now()/1000 }); |
| |
| wsSend({ type: 'request_sync' }); |
| }); |
|
|
| socket.addEventListener('message', (ev) => { |
| try { |
| const msg = JSON.parse(ev.data); |
| |
| 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') { |
| |
| wsSend({ type: 'request_sync' }); |
| } else if (msg.type === 'pong') { |
| |
| 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)); |
| } |
|
|
| |
| document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', ()=> switchTab(t.dataset.tab))); |
| btnCopy.addEventListener('click', copyRoom); |
|
|
| |
| 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.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.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 (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); |
| } |
| } |
| }); |
|
|
| |
| 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'; |
| } |
| }); |
|
|
| |
| btnEnableAudio.addEventListener('click', async ()=> { |
| try { await audio.play(); audio.pause(); btnEnableAudio.style.display='none'; } catch(e){ console.warn(e); } |
| }); |
|
|
| |
| btnCopy.addEventListener('click', copyRoom); |
|
|
| |
| connectWs(); |
| })(); |
|
|