From a631bc074c25c0a2aabf97f06dbac49861b9ab9a Mon Sep 17 00:00:00 2001 From: steveseguin Date: Sun, 18 Jan 2026 03:26:26 -0500 Subject: [PATCH] v29.0 --- auth-client.js | 1696 +- auth-styles.css | 682 +- clipboard.html | 1580 +- cloud.html | 294 +- core/audio/meter.worklet.js | 138 +- core/audio/meters.js | 166 +- core/events/event-bus.js | 28 +- core/events/level-bus.js | 38 +- core/index.js | 14 +- core/legacy/meter-bridge.js | 150 +- core/legacy/session-bridge.js | 116 +- core/recording/index.js | 6 +- core/recording/multitrack-recorder.js | 838 +- core/recording/track-recorder.js | 268 +- core/recording/wav-encoder.js | 461 +- core/uploads/cloud-storage.js | 484 +- core/uploads/index.js | 2 +- devices.css | 262 +- iframe.css | 400 +- iframe.html | 2596 +- images/hd.svg | 4 +- images/sd.svg | 4 +- index.html | 158 +- lib.js | 4817 +- main.css | 15188 ++++--- main.js | 324 +- media/camera_inkscape.svg | 146 +- media/grid_inkscape.svg | 176 +- media/icon.svg | 300 +- media/icon_gradient.svg | 270 +- media/monitor_inkscape.svg | 158 +- media/plane_inkscape.svg | 138 +- media/thirdshead.svg | 20 +- media/vdoninja.svg | 200 +- mixer.html | 2539 +- multi.html | 1236 +- podcast/audio-metering.md | 98 +- podcast/bootstrap.js | 14 +- podcast/index.html | 13 + podcast/recording-flow.md | 78 +- podcast/studio.css | 3898 +- podcast/studio.js | 9142 ++-- popout.html | 245 +- recorder/monitor.html | 820 +- thirdparty/CodecsHandler.js | 1440 +- thirdparty/StreamSaver.js | 652 +- thirdparty/StreamSaver_legacy.js | 632 +- thirdparty/mitm.html | 16 + thirdparty/video.js | 55669 ++++++++++++++++++++++++ timecode.html | 562 +- tts.html | 812 +- webrtc.js | 4 +- whep.html | 596 +- whiteboard.html | 2702 +- zoom.html | 364 +- 55 files changed, 88062 insertions(+), 25592 deletions(-) create mode 100644 podcast/index.html create mode 100644 thirdparty/video.js diff --git a/auth-client.js b/auth-client.js index f6b5c97..892a166 100644 --- a/auth-client.js +++ b/auth-client.js @@ -1,848 +1,848 @@ -/* VDO.Ninja Authentication Client Integration */ - -// Configuration -const AUTH_SERVICE_URL = 'https://vdo-ninja-auth-service.vdo.workers.dev'; // Change for local dev: http://localhost:8787 - -// Authentication state -session.authMode = false; -session.requireAuth = false; -session.authToken = null; -session.authUser = null; -session.authStreamMapping = {}; -session.handleToStream = {}; - - -// Initialize authentication -async function initAuthentication() { - - // Check URL parameters for universal token first - if (urlParams.has("universaltoken")) { - session.universalToken = urlParams.get("universaltoken"); - session.authMode = true; - console.log('Universal token detected:', session.universalToken); - // Universal tokens bypass auth requirement for viewing - if (session.view || session.scene || session.solo) { - session.requireAuth = false; - console.log('Auth requirement bypassed for viewing'); - } - } - - // Check URL parameters - if (urlParams.has("auth") || urlParams.has("requireauth")) { - session.authMode = true; - session.requireAuth = urlParams.has("requireauth"); - - // Check for existing auth token in localStorage - const storedToken = localStorage.getItem('vdo_auth_token'); - if (storedToken) { - try { - // Validate token is still valid - const payload = JSON.parse(atob(storedToken.split('.')[1])); - if (payload.exp > Date.now() / 1000) { - session.authToken = storedToken; - await populateUserInfo(); - } else { - localStorage.removeItem('vdo_auth_token'); - } - } catch (e) { - localStorage.removeItem('vdo_auth_token'); - } - } - - // Check for auth token in URL (after OAuth redirect) - if (urlParams.has("authtoken")) { - session.authToken = urlParams.get("authtoken"); - localStorage.setItem('vdo_auth_token', session.authToken); - - // Clean URL - const url = new URL(window.location.href); - url.searchParams.delete('authtoken'); - window.history.replaceState({}, document.title, url.toString()); - - await populateUserInfo(); - } - - // Check if we need to verify room requirements - if (!session.authToken && session.authMode && (urlParams.has("room") || urlParams.has("roomid") || urlParams.has("r"))) { - const roomId = urlParams.get("room") || urlParams.get("roomid") || urlParams.get("r"); - if (roomId) { - // Check if this room requires auth - try { - const roomInfo = await checkRoomAccess(roomId, urlParams.has("director") || urlParams.has("dir")); - if (roomInfo.requiresAuth) { - session.requireAuth = true; - } - } catch (e) { - console.log('Could not check room requirements:', e); - } - } - } - - // Show auth UI if required and not authenticated - if (!session.authToken && (session.requireAuth || session.director)) { - // If the page is in auth mode or the director is attempting to use auth, - // encourage sign-in proactively. - showAuthUI(); - } - } -} - -// Show authentication UI -function showAuthUI(options = {}) { - const authContainer = document.createElement('div'); - authContainer.id = 'auth-container'; - authContainer.innerHTML = ` -
-

Sign in to VDO.Ninja

-

${options.message || 'Sign in to claim your personal stream ID and enable advanced features'}

- -
- - - -
- - ${(!session.requireAuth && !options.requireAuth) ? '' : ''} -
- `; - - document.body.appendChild(authContainer); -} - -// Social sign-in handler -function socialSignIn(provider) { - const returnUrl = encodeURIComponent(window.location.href); - window.location.href = `${AUTH_SERVICE_URL}/auth/${provider}?returnUrl=${returnUrl}`; -} - -// Skip authentication -function skipAuth() { - const authContainer = document.getElementById('auth-container'); - if (authContainer) { - authContainer.remove(); - } - session.authSkipped = true; -} - -// Populate user info from auth token -async function populateUserInfo() { - if (!session.authToken) return; - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/user/info`, { - headers: { 'Authorization': `Bearer ${session.authToken}` } - }); - - if (response.ok) { - const userInfo = await response.json(); - session.authUser = userInfo; - - // Auto-populate label if not set - if (!session.label && userInfo.displayName) { - session.label = userInfo.displayName; - if (document.getElementById("label_input")) { - document.getElementById("label_input").value = session.label; - } - } - - // Auto-populate avatar if not set - if (!session.avatar && userInfo.avatar) { - session.avatar = userInfo.avatar; - updateAvatarDisplay(); - } - - // Store user handle - session.userHandle = userInfo.userHandle; - - // Show user info in UI - showUserInfo(userInfo); - } - } catch (e) { - console.error("Failed to get user info:", e); - } -} - -// Show user info in UI -function showUserInfo(userInfo) { - const existingDisplay = document.getElementById('user-info-display'); - if (existingDisplay) { - existingDisplay.remove(); - } - - const userDisplay = document.createElement('div'); - userDisplay.id = 'user-info-display'; - userDisplay.className = 'user-info-display'; - userDisplay.innerHTML = ` - ${userInfo.displayName} -
-
${userInfo.displayName}
-
${userInfo.userHandle}
-
- `; - - // Add to appropriate location based on current view - const targetElement = document.querySelector('.header-container') || document.querySelector('.container'); - if (targetElement) { - targetElement.insertBefore(userDisplay, targetElement.firstChild); - } -} - -// Assign authenticated stream ID -async function assignAuthStream() { - if (!session.authToken || session.authStreamAssigned) return; - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/assign`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - roomId: session.roomid || 'lobby', - deviceLabel: session.streamID || 'camera', - useEncryption: false // Disabled for now until fully tested - }) - }); - - if (response.ok) { - const assignment = await response.json(); - - // Store original stream ID - session.originalStreamID = session.streamID; - - // Use assigned stream ID - session.streamID = assignment.streamId; - session.streamSecret = assignment.streamSecret; - session.authStreamAssigned = true; - - console.log("Assigned authenticated stream:", assignment.streamId); - - // Update any UI showing stream ID - updateStreamIDDisplay(); - } - } catch (e) { - console.error("Failed to assign auth stream:", e); - } -} - -// Generate stream authentication signature -async function generateStreamSignature() { - if (!session.streamSecret) return null; - - const timestamp = Date.now(); - const message = `${session.streamID}:${timestamp}`; - - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(session.streamSecret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - - const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); - const hexSignature = Array.from(new Uint8Array(signature)) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); - - return { - streamId: session.streamID, - userHandle: session.userHandle, - timestamp: timestamp, - signature: hexSignature - }; -} - -// Validate incoming stream authentication -async function validateStreamAuth(streamId, authData) { - if (!session.authToken || !authData) return true; - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/verify`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - streamId: streamId, - auth: authData - }) - }); - - if (response.ok) { - const result = await response.json(); - if (result.valid && result.userInfo) { - // Store user info for this stream - session.authStreamMapping[streamId] = result.userInfo; - - // Update UI if this is a director view - if (session.director) { - updateStreamDisplay(streamId, result.userInfo); - } - } - return result.valid; - } - } catch (e) { - console.error("Stream validation failed:", e); - } - - return false; -} - -// Resolve view handles (e.g., @johndoe) to stream IDs -async function resolveViewHandles(viewList) { - if (!session.authToken) return viewList; - - const resolved = []; - - for (const target of viewList) { - if (target.startsWith('@')) { - // User handle - resolve to current stream - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/user/${target}`, { - headers: { 'Authorization': `Bearer ${session.authToken}` } - }); - - if (response.ok) { - const data = await response.json(); - if (data.currentStreamId) { - resolved.push(data.currentStreamId); - // Store mapping for UI - session.handleToStream[target] = data; - } - } - } catch (e) { - console.error(`Failed to resolve handle ${target}:`, e); - } - } else { - resolved.push(target); - } - } - - return resolved; -} - -// Check room access -async function checkRoomAccess(roomIdOrAlias, isDirector = false) { - console.log('Checking room access for:', roomIdOrAlias, 'with universal token:', session.universalToken); - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/access`, { - method: 'POST', - headers: { - 'Authorization': session.authToken ? `Bearer ${session.authToken}` : '', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - room: roomIdOrAlias, - isDirector: isDirector, - universalToken: session.universalToken || null - }) - }); - - const data = await response.json(); - console.log('Room access response:', response.status, data); - - // Handle room not found case - if (response.status === 404 && data && data.error === 'Room not found') { - // In auth mode, non-existent rooms can be created by authenticated users - if (session.authToken) { - // Allow authenticated users to proceed - room will be created on first join - return { - roomId: roomIdOrAlias, - alias: roomIdOrAlias, - displayName: roomIdOrAlias, - requiresAuth: false, - hasAccess: true, - isNew: true - }; - } else { - // Require auth to create new rooms - return { - alias: roomIdOrAlias, - displayName: roomIdOrAlias, - requiresAuth: true, - hasAccess: false, - accessDenied: true, - denialReason: 'Sign in to create or join this room' - }; - } - } - - return data; -} - -// Join room with authentication -async function joinRoomWithAuth(roomIdOrAlias) { - // If director is using auth mode but not signed in yet, force sign in first - if (session.director && session.authMode && !session.authToken && !session.universalToken) { - const roomLabel = roomIdOrAlias || 'this room'; - showAuthUI({ - message: `Sign in to manage "${roomLabel}"`, - requireAuth: true - }); - return false; - } - // If we have a universal token, validate it first - if (session.universalToken) { - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/validate-universal`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - token: session.universalToken, - roomId: roomIdOrAlias - }) - }); - - if (response.ok) { - const result = await response.json(); - if (result.valid) { - // Universal token is valid, bypass normal auth - session.roomid = roomIdOrAlias; - return true; - } - } - } catch (e) { - console.error('Failed to validate universal token:', e); - } - } - - const roomInfo = await checkRoomAccess(roomIdOrAlias, session.director); - - if (roomInfo.requiresAuth && !session.authToken && !session.universalToken) { - if (session.authSkipped) { - // User already chose to skip auth, show access denied instead of auth UI - showAccessDeniedUI({ - ...roomInfo, - denialReason: 'This room requires authentication. Please reload the page and sign in to join.', - requestAccessUrl: null - }); - return false; - } else { - // First time seeing auth requirement for this room - const displayLabel = roomInfo.displayName || roomInfo.alias || roomIdOrAlias || roomInfo.roomId || 'this room'; - showAuthUI({ - message: `Sign in to join "${displayLabel}"`, - requireAuth: true - }); - return false; - } - } - - if (roomInfo.accessDenied) { - showAccessDeniedUI(roomInfo); - return false; - } - - // Important: For auth rooms, we need to use the original alias for hashing - // The auth service tracks by the real room ID, but VDO uses the alias - if (roomInfo.alias && roomInfo.alias === roomIdOrAlias) { - // User provided the alias, keep using it - session.roomid = roomIdOrAlias; - } else if (roomInfo.roomId === roomIdOrAlias) { - // User provided the real room ID - session.roomid = roomInfo.alias || roomIdOrAlias; - } else { - // Default case - session.roomid = roomInfo.alias || roomInfo.roomId; - } - - session.roomAlias = roomInfo.alias; - session.realRoomId = roomInfo.roomId; - - return true; -} - -// Show access denied UI -function showAccessDeniedUI(roomInfo) { - const modal = document.createElement('div'); - modal.id = 'auth-container'; - modal.innerHTML = ` -
-

Access Denied

-

${roomInfo.denialReason}

- ${roomInfo.requestAccessUrl ? - `` : - '' - } -
- `; - - document.body.appendChild(modal); -} - -// Request room access -async function requestRoomAccess(roomId) { - if (!session.authToken) { - showAuthUI({ message: 'Sign in to request access' }); - return; - } - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request-access/${roomId}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}` - } - }); - - if (response.ok) { - alert('Access request sent! The room owner will review your request.'); - document.getElementById('auth-container').remove(); - } - } catch (e) { - console.error('Failed to request access:', e); - } -} - -// Update stream display with user info -function updateStreamDisplay(streamId, userInfo) { - // Update control box if it exists - const controlBox = document.getElementById(`controls_${streamId}`); - if (controlBox && userInfo) { - const header = controlBox.querySelector('.header'); - if (header && !header.querySelector('.user-auth-badge')) { - const badge = document.createElement('div'); - badge.className = 'user-auth-badge'; - badge.innerHTML = ` - ${userInfo.displayName} - ${userInfo.userHandle} - ${userInfo.provider} - `; - header.appendChild(badge); - } - } - - // Update any labels showing stream ID - const labels = document.querySelectorAll(`[data-stream-id="${streamId}"]`); - labels.forEach(label => { - if (userInfo && !label.dataset.updated) { - label.dataset.updated = 'true'; - label.textContent = userInfo.displayName || userInfo.userHandle; - } - }); -} - -// Update avatar display -function updateAvatarDisplay() { - if (session.avatar) { - // Update any avatar displays in the UI - const avatarElements = document.querySelectorAll('.avatar-display'); - avatarElements.forEach(el => { - el.src = session.avatar; - }); - } -} - -// Update stream ID display -function updateStreamIDDisplay() { - // Update any UI elements showing the stream ID - const streamIdElements = document.querySelectorAll('.stream-id-display'); - streamIdElements.forEach(el => { - el.textContent = session.originalStreamID || session.streamID; - }); -} - -// Resolve any stream ID (encrypted or not) through auth service -async function resolveStream(streamId) { - if (!session.authToken && !session.universalToken) { - return { error: 'Not authenticated' }; - } - - try { - const headers = { - 'Content-Type': 'application/json' - }; - - if (session.authToken) { - headers['Authorization'] = `Bearer ${session.authToken}`; - } - - const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/resolve`, { - method: 'POST', - headers: headers, - body: JSON.stringify({ - streamId: streamId, - roomId: session.roomid, - universalToken: session.universalToken - }) - }); - - if (response.ok) { - const data = await response.json(); - return data; - } else if (response.status === 403) { - return { error: 'Access denied' }; - } else if (response.status === 404) { - return { error: 'Stream not found' }; - } - } catch (e) { - console.error('Failed to resolve stream:', e); - return { error: 'Failed to resolve stream' }; - } - - return { error: 'Unknown error' }; -} - -// Get encryption key for viewing a stream -async function getStreamKey(streamId) { - if (!session.authToken) return null; - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/key`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - streamId: streamId, - roomId: session.roomid - }) - }); - - if (response.ok) { - const data = await response.json(); - return data; - } - } catch (e) { - console.error('Failed to get stream key:', e); - } - - return null; -} - -// Decrypt stream ID using XOR cipher -async function decryptStreamId(encryptedId, key) { - // Add padding if needed - const base64 = encryptedId - .replace(/-/g, '+') - .replace(/_/g, '/') - .padEnd(encryptedId.length + (4 - encryptedId.length % 4) % 4, '='); - - const encrypted = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); - const keyData = new TextEncoder().encode(key); - - const decrypted = new Uint8Array(encrypted.length); - for (let i = 0; i < encrypted.length; i++) { - decrypted[i] = encrypted[i] ^ keyData[i % keyData.length]; - } - - return new TextDecoder().decode(decrypted); -} - -// Heartbeat to keep stream active -function startAuthHeartbeat() { - if (!session.authToken || !session.streamID) return; - - setInterval(async () => { - if (session.authToken && session.streamID && session.authStreamAssigned) { - try { - await fetch(`${AUTH_SERVICE_URL}/api/stream/heartbeat`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - streamId: session.streamID, - roomId: session.roomid || 'lobby' - }) - }); - } catch (e) { - console.error('Heartbeat failed:', e); - } - } - }, 30000); // Every 30 seconds -} - -// Create a universal token for view/scene links -async function createUniversalToken() { - if (!session.authToken || !session.roomid) { - console.error('Must be authenticated and in a room to create universal token'); - return null; - } - - try { - console.log('Creating universal token for room:', session.roomid); - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/universal-token`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - roomId: session.roomid, - description: 'View/Scene access token' - }) - }); - - if (response.ok) { - const data = await response.json(); - session.universalViewToken = data.token; - console.log('Created universal token:', data.token); - - // Update all existing solo links - updateAllSoloLinks(); - - return data.token; - } else { - console.error('Failed to create universal token:', response.status); - } - } catch (e) { - console.error('Failed to create universal token:', e); - } - - return null; -} - -// Update all solo link displays with new token -function updateAllSoloLinks() { - // Update all solo link inputs and displays - document.querySelectorAll('[data-sololink]').forEach(ele => { - const uuid = ele.getAttribute('data--u-u-i-d'); - if (uuid && session.rpcs[uuid]) { - const soloLink = soloLinkGenerator(session.rpcs[uuid].streamID, false); - if (ele.tagName === 'INPUT') { - ele.value = soloLink; - } else if (ele.tagName === 'A') { - ele.href = soloLink; - ele.innerText = soloLink; - } - } - }); - - // Update director's own solo link if present - const directorLink = document.querySelector('#grabDirectorSoloLink'); - if (directorLink && session.streamID) { - const soloLink = soloLinkGenerator(session.streamID, true); - directorLink.dataset.raw = soloLink; - directorLink.href = soloLink; - directorLink.innerText = soloLink; - } - - // Update solo links in control boxes - document.querySelectorAll('.soloLink').forEach(ele => { - if (ele.getAttribute('value')) { - const baseUrl = ele.getAttribute('value'); - // Extract stream ID from the base URL - const match = baseUrl.match(/[?&]view=([^&]+)/); - if (match && match[1]) { - const streamId = match[1]; - const soloLink = soloLinkGenerator(streamId, false); - ele.href = soloLink; - ele.innerHTML = soloLink; - } - } - }); -} - -// Update room settings (access mode, allowlist) -async function updateRoomSettings(roomId, settings) { - if (!session.authToken) { - console.error('Must be authenticated to update room settings'); - return null; - } - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/settings/${roomId}`, { - method: 'PUT', - headers: { - 'Authorization': `Bearer ${session.authToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(settings) - }); - - if (response.ok) { - const data = await response.json(); - console.log('Room settings updated'); - return data; - } else { - console.error('Failed to update room settings:', response.status); - } - } catch (e) { - console.error('Failed to update room settings:', e); - } - - return null; -} - -// Get pending access requests for a room -async function getRoomAccessRequests(roomId) { - if (!session.authToken) { - console.error('Must be authenticated to get access requests'); - return []; - } - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/requests/${roomId}`, { - headers: { - 'Authorization': `Bearer ${session.authToken}` - } - }); - - if (response.ok) { - return await response.json(); - } - } catch (e) { - console.error('Failed to get access requests:', e); - } - - return []; -} - -// Approve or deny an access request -async function handleAccessRequest(roomId, userId, action) { - if (!session.authToken) { - console.error('Must be authenticated to handle access requests'); - return false; - } - - try { - const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request/${roomId}/${userId}/${action}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.authToken}` - } - }); - - return response.ok; - } catch (e) { - console.error('Failed to handle access request:', e); - } - - return false; -} - -// Export functions for use in main VDO.Ninja code -window.vdoAuth = { - init: initAuthentication, - assignStream: assignAuthStream, - generateSignature: generateStreamSignature, - validateStream: validateStreamAuth, - resolveHandles: resolveViewHandles, - checkRoomAccess: checkRoomAccess, - joinRoom: joinRoomWithAuth, - startHeartbeat: startAuthHeartbeat, - getStreamKey: getStreamKey, - decryptStreamId: decryptStreamId, - resolveStream: resolveStream, - createUniversalToken: createUniversalToken, - updateRoomSettings: updateRoomSettings, - getRoomAccessRequests: getRoomAccessRequests, - handleAccessRequest: handleAccessRequest -}; +/* VDO.Ninja Authentication Client Integration */ + +// Configuration +const AUTH_SERVICE_URL = 'https://vdo-ninja-auth-service.vdo.workers.dev'; // Change for local dev: http://localhost:8787 + +// Authentication state +session.authMode = false; +session.requireAuth = false; +session.authToken = null; +session.authUser = null; +session.authStreamMapping = {}; +session.handleToStream = {}; + + +// Initialize authentication +async function initAuthentication() { + + // Check URL parameters for universal token first + if (urlParams.has("universaltoken")) { + session.universalToken = urlParams.get("universaltoken"); + session.authMode = true; + console.log('Universal token detected:', session.universalToken); + // Universal tokens bypass auth requirement for viewing + if (session.view || session.scene || session.solo) { + session.requireAuth = false; + console.log('Auth requirement bypassed for viewing'); + } + } + + // Check URL parameters + if (urlParams.has("auth") || urlParams.has("requireauth")) { + session.authMode = true; + session.requireAuth = urlParams.has("requireauth"); + + // Check for existing auth token in localStorage + const storedToken = localStorage.getItem('vdo_auth_token'); + if (storedToken) { + try { + // Validate token is still valid + const payload = JSON.parse(atob(storedToken.split('.')[1])); + if (payload.exp > Date.now() / 1000) { + session.authToken = storedToken; + await populateUserInfo(); + } else { + localStorage.removeItem('vdo_auth_token'); + } + } catch (e) { + localStorage.removeItem('vdo_auth_token'); + } + } + + // Check for auth token in URL (after OAuth redirect) + if (urlParams.has("authtoken")) { + session.authToken = urlParams.get("authtoken"); + localStorage.setItem('vdo_auth_token', session.authToken); + + // Clean URL + const url = new URL(window.location.href); + url.searchParams.delete('authtoken'); + window.history.replaceState({}, document.title, url.toString()); + + await populateUserInfo(); + } + + // Check if we need to verify room requirements + if (!session.authToken && session.authMode && (urlParams.has("room") || urlParams.has("roomid") || urlParams.has("r"))) { + const roomId = urlParams.get("room") || urlParams.get("roomid") || urlParams.get("r"); + if (roomId) { + // Check if this room requires auth + try { + const roomInfo = await checkRoomAccess(roomId, urlParams.has("director") || urlParams.has("dir")); + if (roomInfo.requiresAuth) { + session.requireAuth = true; + } + } catch (e) { + console.log('Could not check room requirements:', e); + } + } + } + + // Show auth UI if required and not authenticated + if (!session.authToken && (session.requireAuth || session.director)) { + // If the page is in auth mode or the director is attempting to use auth, + // encourage sign-in proactively. + showAuthUI(); + } + } +} + +// Show authentication UI +function showAuthUI(options = {}) { + const authContainer = document.createElement('div'); + authContainer.id = 'auth-container'; + authContainer.innerHTML = ` +
+

Sign in to VDO.Ninja

+

${options.message || 'Sign in to claim your personal stream ID and enable advanced features'}

+ +
+ + + +
+ + ${(!session.requireAuth && !options.requireAuth) ? '' : ''} +
+ `; + + document.body.appendChild(authContainer); +} + +// Social sign-in handler +function socialSignIn(provider) { + const returnUrl = encodeURIComponent(window.location.href); + window.location.href = `${AUTH_SERVICE_URL}/auth/${provider}?returnUrl=${returnUrl}`; +} + +// Skip authentication +function skipAuth() { + const authContainer = document.getElementById('auth-container'); + if (authContainer) { + authContainer.remove(); + } + session.authSkipped = true; +} + +// Populate user info from auth token +async function populateUserInfo() { + if (!session.authToken) return; + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/user/info`, { + headers: { 'Authorization': `Bearer ${session.authToken}` } + }); + + if (response.ok) { + const userInfo = await response.json(); + session.authUser = userInfo; + + // Auto-populate label if not set + if (!session.label && userInfo.displayName) { + session.label = userInfo.displayName; + if (document.getElementById("label_input")) { + document.getElementById("label_input").value = session.label; + } + } + + // Auto-populate avatar if not set + if (!session.avatar && userInfo.avatar) { + session.avatar = userInfo.avatar; + updateAvatarDisplay(); + } + + // Store user handle + session.userHandle = userInfo.userHandle; + + // Show user info in UI + showUserInfo(userInfo); + } + } catch (e) { + console.error("Failed to get user info:", e); + } +} + +// Show user info in UI +function showUserInfo(userInfo) { + const existingDisplay = document.getElementById('user-info-display'); + if (existingDisplay) { + existingDisplay.remove(); + } + + const userDisplay = document.createElement('div'); + userDisplay.id = 'user-info-display'; + userDisplay.className = 'user-info-display'; + userDisplay.innerHTML = ` + ${userInfo.displayName} +
+
${userInfo.displayName}
+
${userInfo.userHandle}
+
+ `; + + // Add to appropriate location based on current view + const targetElement = document.querySelector('.header-container') || document.querySelector('.container'); + if (targetElement) { + targetElement.insertBefore(userDisplay, targetElement.firstChild); + } +} + +// Assign authenticated stream ID +async function assignAuthStream() { + if (!session.authToken || session.authStreamAssigned) return; + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/assign`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + roomId: session.roomid || 'lobby', + deviceLabel: session.streamID || 'camera', + useEncryption: false // Disabled for now until fully tested + }) + }); + + if (response.ok) { + const assignment = await response.json(); + + // Store original stream ID + session.originalStreamID = session.streamID; + + // Use assigned stream ID + session.streamID = assignment.streamId; + session.streamSecret = assignment.streamSecret; + session.authStreamAssigned = true; + + console.log("Assigned authenticated stream:", assignment.streamId); + + // Update any UI showing stream ID + updateStreamIDDisplay(); + } + } catch (e) { + console.error("Failed to assign auth stream:", e); + } +} + +// Generate stream authentication signature +async function generateStreamSignature() { + if (!session.streamSecret) return null; + + const timestamp = Date.now(); + const message = `${session.streamID}:${timestamp}`; + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(session.streamSecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); + const hexSignature = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return { + streamId: session.streamID, + userHandle: session.userHandle, + timestamp: timestamp, + signature: hexSignature + }; +} + +// Validate incoming stream authentication +async function validateStreamAuth(streamId, authData) { + if (!session.authToken || !authData) return true; + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/verify`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + streamId: streamId, + auth: authData + }) + }); + + if (response.ok) { + const result = await response.json(); + if (result.valid && result.userInfo) { + // Store user info for this stream + session.authStreamMapping[streamId] = result.userInfo; + + // Update UI if this is a director view + if (session.director) { + updateStreamDisplay(streamId, result.userInfo); + } + } + return result.valid; + } + } catch (e) { + console.error("Stream validation failed:", e); + } + + return false; +} + +// Resolve view handles (e.g., @johndoe) to stream IDs +async function resolveViewHandles(viewList) { + if (!session.authToken) return viewList; + + const resolved = []; + + for (const target of viewList) { + if (target.startsWith('@')) { + // User handle - resolve to current stream + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/user/${target}`, { + headers: { 'Authorization': `Bearer ${session.authToken}` } + }); + + if (response.ok) { + const data = await response.json(); + if (data.currentStreamId) { + resolved.push(data.currentStreamId); + // Store mapping for UI + session.handleToStream[target] = data; + } + } + } catch (e) { + console.error(`Failed to resolve handle ${target}:`, e); + } + } else { + resolved.push(target); + } + } + + return resolved; +} + +// Check room access +async function checkRoomAccess(roomIdOrAlias, isDirector = false) { + console.log('Checking room access for:', roomIdOrAlias, 'with universal token:', session.universalToken); + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/access`, { + method: 'POST', + headers: { + 'Authorization': session.authToken ? `Bearer ${session.authToken}` : '', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + room: roomIdOrAlias, + isDirector: isDirector, + universalToken: session.universalToken || null + }) + }); + + const data = await response.json(); + console.log('Room access response:', response.status, data); + + // Handle room not found case + if (response.status === 404 && data && data.error === 'Room not found') { + // In auth mode, non-existent rooms can be created by authenticated users + if (session.authToken) { + // Allow authenticated users to proceed - room will be created on first join + return { + roomId: roomIdOrAlias, + alias: roomIdOrAlias, + displayName: roomIdOrAlias, + requiresAuth: false, + hasAccess: true, + isNew: true + }; + } else { + // Require auth to create new rooms + return { + alias: roomIdOrAlias, + displayName: roomIdOrAlias, + requiresAuth: true, + hasAccess: false, + accessDenied: true, + denialReason: 'Sign in to create or join this room' + }; + } + } + + return data; +} + +// Join room with authentication +async function joinRoomWithAuth(roomIdOrAlias) { + // If director is using auth mode but not signed in yet, force sign in first + if (session.director && session.authMode && !session.authToken && !session.universalToken) { + const roomLabel = roomIdOrAlias || 'this room'; + showAuthUI({ + message: `Sign in to manage "${roomLabel}"`, + requireAuth: true + }); + return false; + } + // If we have a universal token, validate it first + if (session.universalToken) { + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/validate-universal`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: session.universalToken, + roomId: roomIdOrAlias + }) + }); + + if (response.ok) { + const result = await response.json(); + if (result.valid) { + // Universal token is valid, bypass normal auth + session.roomid = roomIdOrAlias; + return true; + } + } + } catch (e) { + console.error('Failed to validate universal token:', e); + } + } + + const roomInfo = await checkRoomAccess(roomIdOrAlias, session.director); + + if (roomInfo.requiresAuth && !session.authToken && !session.universalToken) { + if (session.authSkipped) { + // User already chose to skip auth, show access denied instead of auth UI + showAccessDeniedUI({ + ...roomInfo, + denialReason: 'This room requires authentication. Please reload the page and sign in to join.', + requestAccessUrl: null + }); + return false; + } else { + // First time seeing auth requirement for this room + const displayLabel = roomInfo.displayName || roomInfo.alias || roomIdOrAlias || roomInfo.roomId || 'this room'; + showAuthUI({ + message: `Sign in to join "${displayLabel}"`, + requireAuth: true + }); + return false; + } + } + + if (roomInfo.accessDenied) { + showAccessDeniedUI(roomInfo); + return false; + } + + // Important: For auth rooms, we need to use the original alias for hashing + // The auth service tracks by the real room ID, but VDO uses the alias + if (roomInfo.alias && roomInfo.alias === roomIdOrAlias) { + // User provided the alias, keep using it + session.roomid = roomIdOrAlias; + } else if (roomInfo.roomId === roomIdOrAlias) { + // User provided the real room ID + session.roomid = roomInfo.alias || roomIdOrAlias; + } else { + // Default case + session.roomid = roomInfo.alias || roomInfo.roomId; + } + + session.roomAlias = roomInfo.alias; + session.realRoomId = roomInfo.roomId; + + return true; +} + +// Show access denied UI +function showAccessDeniedUI(roomInfo) { + const modal = document.createElement('div'); + modal.id = 'auth-container'; + modal.innerHTML = ` +
+

Access Denied

+

${roomInfo.denialReason}

+ ${roomInfo.requestAccessUrl ? + `` : + '' + } +
+ `; + + document.body.appendChild(modal); +} + +// Request room access +async function requestRoomAccess(roomId) { + if (!session.authToken) { + showAuthUI({ message: 'Sign in to request access' }); + return; + } + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request-access/${roomId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}` + } + }); + + if (response.ok) { + alert('Access request sent! The room owner will review your request.'); + document.getElementById('auth-container').remove(); + } + } catch (e) { + console.error('Failed to request access:', e); + } +} + +// Update stream display with user info +function updateStreamDisplay(streamId, userInfo) { + // Update control box if it exists + const controlBox = document.getElementById(`controls_${streamId}`); + if (controlBox && userInfo) { + const header = controlBox.querySelector('.header'); + if (header && !header.querySelector('.user-auth-badge')) { + const badge = document.createElement('div'); + badge.className = 'user-auth-badge'; + badge.innerHTML = ` + ${userInfo.displayName} + ${userInfo.userHandle} + ${userInfo.provider} + `; + header.appendChild(badge); + } + } + + // Update any labels showing stream ID + const labels = document.querySelectorAll(`[data-stream-id="${streamId}"]`); + labels.forEach(label => { + if (userInfo && !label.dataset.updated) { + label.dataset.updated = 'true'; + label.textContent = userInfo.displayName || userInfo.userHandle; + } + }); +} + +// Update avatar display +function updateAvatarDisplay() { + if (session.avatar) { + // Update any avatar displays in the UI + const avatarElements = document.querySelectorAll('.avatar-display'); + avatarElements.forEach(el => { + el.src = session.avatar; + }); + } +} + +// Update stream ID display +function updateStreamIDDisplay() { + // Update any UI elements showing the stream ID + const streamIdElements = document.querySelectorAll('.stream-id-display'); + streamIdElements.forEach(el => { + el.textContent = session.originalStreamID || session.streamID; + }); +} + +// Resolve any stream ID (encrypted or not) through auth service +async function resolveStream(streamId) { + if (!session.authToken && !session.universalToken) { + return { error: 'Not authenticated' }; + } + + try { + const headers = { + 'Content-Type': 'application/json' + }; + + if (session.authToken) { + headers['Authorization'] = `Bearer ${session.authToken}`; + } + + const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/resolve`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + streamId: streamId, + roomId: session.roomid, + universalToken: session.universalToken + }) + }); + + if (response.ok) { + const data = await response.json(); + return data; + } else if (response.status === 403) { + return { error: 'Access denied' }; + } else if (response.status === 404) { + return { error: 'Stream not found' }; + } + } catch (e) { + console.error('Failed to resolve stream:', e); + return { error: 'Failed to resolve stream' }; + } + + return { error: 'Unknown error' }; +} + +// Get encryption key for viewing a stream +async function getStreamKey(streamId) { + if (!session.authToken) return null; + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/stream/key`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + streamId: streamId, + roomId: session.roomid + }) + }); + + if (response.ok) { + const data = await response.json(); + return data; + } + } catch (e) { + console.error('Failed to get stream key:', e); + } + + return null; +} + +// Decrypt stream ID using XOR cipher +async function decryptStreamId(encryptedId, key) { + // Add padding if needed + const base64 = encryptedId + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(encryptedId.length + (4 - encryptedId.length % 4) % 4, '='); + + const encrypted = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + const keyData = new TextEncoder().encode(key); + + const decrypted = new Uint8Array(encrypted.length); + for (let i = 0; i < encrypted.length; i++) { + decrypted[i] = encrypted[i] ^ keyData[i % keyData.length]; + } + + return new TextDecoder().decode(decrypted); +} + +// Heartbeat to keep stream active +function startAuthHeartbeat() { + if (!session.authToken || !session.streamID) return; + + setInterval(async () => { + if (session.authToken && session.streamID && session.authStreamAssigned) { + try { + await fetch(`${AUTH_SERVICE_URL}/api/stream/heartbeat`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + streamId: session.streamID, + roomId: session.roomid || 'lobby' + }) + }); + } catch (e) { + console.error('Heartbeat failed:', e); + } + } + }, 30000); // Every 30 seconds +} + +// Create a universal token for view/scene links +async function createUniversalToken() { + if (!session.authToken || !session.roomid) { + console.error('Must be authenticated and in a room to create universal token'); + return null; + } + + try { + console.log('Creating universal token for room:', session.roomid); + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/universal-token`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + roomId: session.roomid, + description: 'View/Scene access token' + }) + }); + + if (response.ok) { + const data = await response.json(); + session.universalViewToken = data.token; + console.log('Created universal token:', data.token); + + // Update all existing solo links + updateAllSoloLinks(); + + return data.token; + } else { + console.error('Failed to create universal token:', response.status); + } + } catch (e) { + console.error('Failed to create universal token:', e); + } + + return null; +} + +// Update all solo link displays with new token +function updateAllSoloLinks() { + // Update all solo link inputs and displays + document.querySelectorAll('[data-sololink]').forEach(ele => { + const uuid = ele.getAttribute('data--u-u-i-d'); + if (uuid && session.rpcs[uuid]) { + const soloLink = soloLinkGenerator(session.rpcs[uuid].streamID, false); + if (ele.tagName === 'INPUT') { + ele.value = soloLink; + } else if (ele.tagName === 'A') { + ele.href = soloLink; + ele.innerText = soloLink; + } + } + }); + + // Update director's own solo link if present + const directorLink = document.querySelector('#grabDirectorSoloLink'); + if (directorLink && session.streamID) { + const soloLink = soloLinkGenerator(session.streamID, true); + directorLink.dataset.raw = soloLink; + directorLink.href = soloLink; + directorLink.innerText = soloLink; + } + + // Update solo links in control boxes + document.querySelectorAll('.soloLink').forEach(ele => { + if (ele.getAttribute('value')) { + const baseUrl = ele.getAttribute('value'); + // Extract stream ID from the base URL + const match = baseUrl.match(/[?&]view=([^&]+)/); + if (match && match[1]) { + const streamId = match[1]; + const soloLink = soloLinkGenerator(streamId, false); + ele.href = soloLink; + ele.innerHTML = soloLink; + } + } + }); +} + +// Update room settings (access mode, allowlist) +async function updateRoomSettings(roomId, settings) { + if (!session.authToken) { + console.error('Must be authenticated to update room settings'); + return null; + } + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/settings/${roomId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${session.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + if (response.ok) { + const data = await response.json(); + console.log('Room settings updated'); + return data; + } else { + console.error('Failed to update room settings:', response.status); + } + } catch (e) { + console.error('Failed to update room settings:', e); + } + + return null; +} + +// Get pending access requests for a room +async function getRoomAccessRequests(roomId) { + if (!session.authToken) { + console.error('Must be authenticated to get access requests'); + return []; + } + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/requests/${roomId}`, { + headers: { + 'Authorization': `Bearer ${session.authToken}` + } + }); + + if (response.ok) { + return await response.json(); + } + } catch (e) { + console.error('Failed to get access requests:', e); + } + + return []; +} + +// Approve or deny an access request +async function handleAccessRequest(roomId, userId, action) { + if (!session.authToken) { + console.error('Must be authenticated to handle access requests'); + return false; + } + + try { + const response = await fetch(`${AUTH_SERVICE_URL}/api/room/request/${roomId}/${userId}/${action}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.authToken}` + } + }); + + return response.ok; + } catch (e) { + console.error('Failed to handle access request:', e); + } + + return false; +} + +// Export functions for use in main VDO.Ninja code +window.vdoAuth = { + init: initAuthentication, + assignStream: assignAuthStream, + generateSignature: generateStreamSignature, + validateStream: validateStreamAuth, + resolveHandles: resolveViewHandles, + checkRoomAccess: checkRoomAccess, + joinRoom: joinRoomWithAuth, + startHeartbeat: startAuthHeartbeat, + getStreamKey: getStreamKey, + decryptStreamId: decryptStreamId, + resolveStream: resolveStream, + createUniversalToken: createUniversalToken, + updateRoomSettings: updateRoomSettings, + getRoomAccessRequests: getRoomAccessRequests, + handleAccessRequest: handleAccessRequest +}; diff --git a/auth-styles.css b/auth-styles.css index 939dcb0..0353f55 100644 --- a/auth-styles.css +++ b/auth-styles.css @@ -1,342 +1,342 @@ -/* Authentication UI Styles */ - -/* Auth Container */ -#auth-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(10px); - z-index: 10000; - display: flex; - align-items: center; - justify-content: center; -} - -.auth-modal { - background: var(--main-bg, #1a1a1a); - padding: 2rem; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - max-width: 400px; - width: 90%; - animation: authModalSlide 0.3s ease-out; -} - -@keyframes authModalSlide { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.auth-modal h2 { - margin: 0 0 0.5rem 0; - color: var(--text-color, #fff); - font-size: 1.5rem; - text-align: center; -} - -.auth-modal p { - color: var(--text-color-secondary, #aaa); - text-align: center; - margin-bottom: 1.5rem; -} - -/* Auth Buttons */ -.auth-buttons { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.auth-button { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.875rem 1.5rem; - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 1rem; - font-weight: 500; - transition: all 0.2s ease; - text-decoration: none; - color: white; -} - -.auth-button:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.auth-button:active { - transform: translateY(0); -} - -.auth-button img { - width: 20px; - height: 20px; -} - -.auth-button.google { - background: #4285f4; -} - -.auth-button.google:hover { - background: #357ae8; -} - -.auth-button.discord { - background: #5865f2; -} - -.auth-button.discord:hover { - background: #4752c4; -} - -.auth-button.twitch { - background: #9146ff; -} - -.auth-button.twitch:hover { - background: #772ce8; -} - -/* Skip Auth Button */ -.skip-auth { - margin-top: 1rem; - padding: 0.75rem; - background: transparent; - border: 1px solid var(--border-color, #444); - color: var(--text-color-secondary, #aaa); - cursor: pointer; - border-radius: 8px; - transition: all 0.2s ease; - width: 100%; - font-size: 0.9rem; -} - -.skip-auth:hover { - border-color: var(--text-color-secondary, #aaa); - color: var(--text-color, #fff); -} - -/* User Info Display */ -.user-info-display { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 1rem; - background: rgba(255, 255, 255, 0.1); - border-radius: 8px; - margin-bottom: 1rem; -} - -.user-info-display img { - width: 32px; - height: 32px; - border-radius: 50%; - border: 2px solid var(--primary-color, #4285f4); -} - -.user-info-display .user-details { - flex: 1; -} - -.user-info-display .user-name { - font-weight: 600; - color: var(--text-color, #fff); - font-size: 0.9rem; -} - -.user-info-display .user-handle { - color: var(--text-color-secondary, #aaa); - font-size: 0.8rem; -} - -/* Auth Badge in Control Boxes */ -.user-auth-badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.75rem; - background: rgba(0, 0, 0, 0.3); - border-radius: 20px; - font-size: 0.8rem; - margin-left: auto; -} - -.user-auth-badge img { - width: 20px; - height: 20px; - border-radius: 50%; -} - -.user-auth-badge .user-handle { - font-weight: 600; - color: var(--primary-color, #4285f4); -} - -.user-auth-badge .user-provider { - font-size: 0.7rem; - opacity: 0.7; - text-transform: capitalize; -} - -.user-auth-badge .user-provider.google { - color: #4285f4; -} - -.user-auth-badge .user-provider.discord { - color: #5865f2; -} - -.user-auth-badge .user-provider.twitch { - color: #9146ff; -} - -/* Access Denied Modal */ -.access-denied-modal { - text-align: center; -} - -.access-denied-modal h3 { - color: #ff4444; - margin-bottom: 1rem; -} - -.access-denied-modal p { - margin-bottom: 1.5rem; -} - -.access-denied-modal button { - padding: 0.75rem 2rem; - background: var(--primary-color, #4285f4); - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 1rem; - transition: all 0.2s ease; -} - -.access-denied-modal button:hover { - background: var(--primary-color-dark, #357ae8); -} - -/* Room Settings Panel */ -.room-settings-panel { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: var(--main-bg, #1a1a1a); - padding: 2rem; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - max-width: 500px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - z-index: 10001; -} - -.room-settings-panel h3 { - margin: 0 0 1.5rem 0; - color: var(--text-color, #fff); -} - -.setting-group { - margin-bottom: 1.5rem; -} - -.setting-group label { - display: block; - margin-bottom: 0.5rem; - color: var(--text-color, #fff); - font-weight: 500; -} - -.setting-group select, -.setting-group input[type="text"] { - width: 100%; - padding: 0.5rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid var(--border-color, #444); - border-radius: 4px; - color: var(--text-color, #fff); -} - -.setting-group input[type="checkbox"] { - margin-right: 0.5rem; -} - -/* Access Requests */ -.access-requests { - background: rgba(255, 165, 0, 0.1); - border: 1px solid rgba(255, 165, 0, 0.3); - border-radius: 8px; - padding: 1rem; - margin: 1rem 0; -} - -.access-requests h4 { - margin: 0 0 1rem 0; - color: #ffa500; -} - -.access-request { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.75rem; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - margin-bottom: 0.5rem; -} - -.access-request img { - width: 40px; - height: 40px; - border-radius: 50%; -} - -.access-request span { - flex: 1; - color: var(--text-color, #fff); -} - -.access-request button { - padding: 0.5rem 1rem; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.875rem; - transition: all 0.2s ease; -} - -.access-request button:first-of-type { - background: #4caf50; - color: white; - margin-right: 0.5rem; -} - -.access-request button:first-of-type:hover { - background: #45a049; -} - -.access-request button:last-of-type { - background: #f44336; - color: white; -} - -.access-request button:last-of-type:hover { - background: #da190b; +/* Authentication UI Styles */ + +/* Auth Container */ +#auth-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-modal { + background: var(--main-bg, #1a1a1a); + padding: 2rem; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 400px; + width: 90%; + animation: authModalSlide 0.3s ease-out; +} + +@keyframes authModalSlide { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.auth-modal h2 { + margin: 0 0 0.5rem 0; + color: var(--text-color, #fff); + font-size: 1.5rem; + text-align: center; +} + +.auth-modal p { + color: var(--text-color-secondary, #aaa); + text-align: center; + margin-bottom: 1.5rem; +} + +/* Auth Buttons */ +.auth-buttons { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.auth-button { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1.5rem; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + text-decoration: none; + color: white; +} + +.auth-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.auth-button:active { + transform: translateY(0); +} + +.auth-button img { + width: 20px; + height: 20px; +} + +.auth-button.google { + background: #4285f4; +} + +.auth-button.google:hover { + background: #357ae8; +} + +.auth-button.discord { + background: #5865f2; +} + +.auth-button.discord:hover { + background: #4752c4; +} + +.auth-button.twitch { + background: #9146ff; +} + +.auth-button.twitch:hover { + background: #772ce8; +} + +/* Skip Auth Button */ +.skip-auth { + margin-top: 1rem; + padding: 0.75rem; + background: transparent; + border: 1px solid var(--border-color, #444); + color: var(--text-color-secondary, #aaa); + cursor: pointer; + border-radius: 8px; + transition: all 0.2s ease; + width: 100%; + font-size: 0.9rem; +} + +.skip-auth:hover { + border-color: var(--text-color-secondary, #aaa); + color: var(--text-color, #fff); +} + +/* User Info Display */ +.user-info-display { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin-bottom: 1rem; +} + +.user-info-display img { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid var(--primary-color, #4285f4); +} + +.user-info-display .user-details { + flex: 1; +} + +.user-info-display .user-name { + font-weight: 600; + color: var(--text-color, #fff); + font-size: 0.9rem; +} + +.user-info-display .user-handle { + color: var(--text-color-secondary, #aaa); + font-size: 0.8rem; +} + +/* Auth Badge in Control Boxes */ +.user-auth-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 20px; + font-size: 0.8rem; + margin-left: auto; +} + +.user-auth-badge img { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.user-auth-badge .user-handle { + font-weight: 600; + color: var(--primary-color, #4285f4); +} + +.user-auth-badge .user-provider { + font-size: 0.7rem; + opacity: 0.7; + text-transform: capitalize; +} + +.user-auth-badge .user-provider.google { + color: #4285f4; +} + +.user-auth-badge .user-provider.discord { + color: #5865f2; +} + +.user-auth-badge .user-provider.twitch { + color: #9146ff; +} + +/* Access Denied Modal */ +.access-denied-modal { + text-align: center; +} + +.access-denied-modal h3 { + color: #ff4444; + margin-bottom: 1rem; +} + +.access-denied-modal p { + margin-bottom: 1.5rem; +} + +.access-denied-modal button { + padding: 0.75rem 2rem; + background: var(--primary-color, #4285f4); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s ease; +} + +.access-denied-modal button:hover { + background: var(--primary-color-dark, #357ae8); +} + +/* Room Settings Panel */ +.room-settings-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--main-bg, #1a1a1a); + padding: 2rem; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + z-index: 10001; +} + +.room-settings-panel h3 { + margin: 0 0 1.5rem 0; + color: var(--text-color, #fff); +} + +.setting-group { + margin-bottom: 1.5rem; +} + +.setting-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-color, #fff); + font-weight: 500; +} + +.setting-group select, +.setting-group input[type="text"] { + width: 100%; + padding: 0.5rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border-color, #444); + border-radius: 4px; + color: var(--text-color, #fff); +} + +.setting-group input[type="checkbox"] { + margin-right: 0.5rem; +} + +/* Access Requests */ +.access-requests { + background: rgba(255, 165, 0, 0.1); + border: 1px solid rgba(255, 165, 0, 0.3); + border-radius: 8px; + padding: 1rem; + margin: 1rem 0; +} + +.access-requests h4 { + margin: 0 0 1rem 0; + color: #ffa500; +} + +.access-request { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + margin-bottom: 0.5rem; +} + +.access-request img { + width: 40px; + height: 40px; + border-radius: 50%; +} + +.access-request span { + flex: 1; + color: var(--text-color, #fff); +} + +.access-request button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.access-request button:first-of-type { + background: #4caf50; + color: white; + margin-right: 0.5rem; +} + +.access-request button:first-of-type:hover { + background: #45a049; +} + +.access-request button:last-of-type { + background: #f44336; + color: white; +} + +.access-request button:last-of-type:hover { + background: #da190b; } \ No newline at end of file diff --git a/clipboard.html b/clipboard.html index 1f01273..9d5459e 100644 --- a/clipboard.html +++ b/clipboard.html @@ -1,791 +1,791 @@ - - - - - - Shared Clipboard - P2P Text Sync | VDO.Ninja - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

🔗 Shared Clipboard

-

Real-time P2P text synchronization across devices

- - - -
-
- Shared Clipboard - 0 characters -
- -
- -
- - Connecting... - -
- - - -
- - -
- -
-

ℹ️ How it works

-
    -
  • Share the link above with other devices or users
  • -
  • Any text typed or pasted will sync automatically
  • -
  • All data is transmitted peer-to-peer (no server storage)
  • -
  • Perfect for quickly sharing text between devices
  • -
-
-
- - - - - - + + + + + + Shared Clipboard - P2P Text Sync | VDO.Ninja + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

🔗 Shared Clipboard

+

Real-time P2P text synchronization across devices

+ + + +
+
+ Shared Clipboard + 0 characters +
+ +
+ +
+ + Connecting... + +
+ + + +
+ + +
+ +
+

ℹ️ How it works

+
    +
  • Share the link above with other devices or users
  • +
  • Any text typed or pasted will sync automatically
  • +
  • All data is transmitted peer-to-peer (no server storage)
  • +
  • Perfect for quickly sharing text between devices
  • +
+
+
+ + + + + + \ No newline at end of file diff --git a/cloud.html b/cloud.html index 2890d16..6749833 100644 --- a/cloud.html +++ b/cloud.html @@ -1,147 +1,147 @@ - - - - - - VDO.Ninja Cloud Sync Setup - - - -
-

VDO.Ninja · Cloud Sync Guide

-

Configure Google Drive & Dropbox uploads

-

- The podcast studio can stream each local recording chunk to cloud storage for redundancy. Use the steps below - to authorize the built-in Google Drive integration or to generate a Dropbox personal access token that the - studio can store locally. -

-
- -
-

Google Drive (built-in OAuth)

-
    -
  1. Open the podcast studio (`?studio=podcast`) and locate the Cloud Sync card.
  2. -
  3. Click Link Google Drive. Google Identity Services opens a popup window.
  4. -
  5. Pick the Google account that will own the uploads and approve the drive.file scope.
  6. -
  7. - Once the popup closes, the status pill switches to “Linked” and future recordings stream into your Drive root - (or the custom folder configured via &gdrivefolder=YourFolder). -
  8. -
-

- The Drive token stays in the browser session. Re-click the button whenever the token expires or if you switch - accounts. -

-
- -
-

Dropbox (OAuth + refresh tokens)

-

- The Dropbox integration now mirrors the Google Drive workflow: clicking Link Dropbox opens an OAuth - popup, requests the files.content.write / files.metadata.write scopes, and stores a - refresh token locally so future sessions can renew access automatically. No server-side helpers are required—the - entire exchange happens in your browser. -

-

Authorize via OAuth

-
    -
  1. Open the podcast studio (`?studio=podcast`) and scroll to the Cloud Sync card.
  2. -
  3. Click Link Dropbox. Allow the popup (make sure your browser isn’t blocking it).
  4. -
  5. - Sign in with the Dropbox account that should receive uploads and approve the requested scopes. The popup will - close once the code exchange completes. -
  6. -
  7. The studio status should switch to “Dropbox linked. Recordings will upload automatically.”
  8. -
-

- Tokens never leave your browser. We store the refresh token (and the most recent short-lived access token) inside - localStorage so background uploads can reconnect silently even after several hours. -

-

Manual fallback token (optional)

-

- If you need an emergency override—e.g., when the OAuth popup cannot run inside a kiosk build—you can still paste - a personal access token into the Dropbox field: -

-
    -
  1. - Visit - https://www.dropbox.com/developers/apps, open your scoped app, and click Generate access token. -
  2. -
  3. Copy the token, paste it into the Cloud Sync token box, then click Link Dropbox.
  4. -
  5. - You can also launch the studio with ?dropbox=YOUR_TOKEN; the textbox will populate automatically. -
  6. -
-

- Dropbox’s generated tokens expire quickly (typically ~4 hours) and do not refresh. Prefer the OAuth link unless - you’re temporarily sidestepping browser restrictions. -

-
- - + + + + + + VDO.Ninja Cloud Sync Setup + + + +
+

VDO.Ninja · Cloud Sync Guide

+

Configure Google Drive & Dropbox uploads

+

+ The podcast studio can stream each local recording chunk to cloud storage for redundancy. Use the steps below + to authorize the built-in Google Drive integration or to generate a Dropbox personal access token that the + studio can store locally. +

+
+ +
+

Google Drive (built-in OAuth)

+
    +
  1. Open the podcast studio (`?studio=podcast`) and locate the Cloud Sync card.
  2. +
  3. Click Link Google Drive. Google Identity Services opens a popup window.
  4. +
  5. Pick the Google account that will own the uploads and approve the drive.file scope.
  6. +
  7. + Once the popup closes, the status pill switches to “Linked” and future recordings stream into your Drive root + (or the custom folder configured via &gdrivefolder=YourFolder). +
  8. +
+

+ The Drive token stays in the browser session. Re-click the button whenever the token expires or if you switch + accounts. +

+
+ +
+

Dropbox (OAuth + refresh tokens)

+

+ The Dropbox integration now mirrors the Google Drive workflow: clicking Link Dropbox opens an OAuth + popup, requests the files.content.write / files.metadata.write scopes, and stores a + refresh token locally so future sessions can renew access automatically. No server-side helpers are required—the + entire exchange happens in your browser. +

+

Authorize via OAuth

+
    +
  1. Open the podcast studio (`?studio=podcast`) and scroll to the Cloud Sync card.
  2. +
  3. Click Link Dropbox. Allow the popup (make sure your browser isn’t blocking it).
  4. +
  5. + Sign in with the Dropbox account that should receive uploads and approve the requested scopes. The popup will + close once the code exchange completes. +
  6. +
  7. The studio status should switch to “Dropbox linked. Recordings will upload automatically.”
  8. +
+

+ Tokens never leave your browser. We store the refresh token (and the most recent short-lived access token) inside + localStorage so background uploads can reconnect silently even after several hours. +

+

Manual fallback token (optional)

+

+ If you need an emergency override—e.g., when the OAuth popup cannot run inside a kiosk build—you can still paste + a personal access token into the Dropbox field: +

+
    +
  1. + Visit + https://www.dropbox.com/developers/apps, open your scoped app, and click Generate access token. +
  2. +
  3. Copy the token, paste it into the Cloud Sync token box, then click Link Dropbox.
  4. +
  5. + You can also launch the studio with ?dropbox=YOUR_TOKEN; the textbox will populate automatically. +
  6. +
+

+ Dropbox’s generated tokens expire quickly (typically ~4 hours) and do not refresh. Prefer the OAuth link unless + you’re temporarily sidestepping browser restrictions. +

+
+ + diff --git a/core/audio/meter.worklet.js b/core/audio/meter.worklet.js index e4eeabf..1fff78e 100644 --- a/core/audio/meter.worklet.js +++ b/core/audio/meter.worklet.js @@ -1,69 +1,69 @@ -class MeterProcessor extends AudioWorkletProcessor { - constructor() { - super(); - this._peak = 0; - this._rms = 0; - this._clipped = 0; - this._frame = 0; - this._silentFrames = 0; - this._updateInterval = Math.round(sampleRate * 0.032); - } - - process(inputs) { - const input = inputs[0]; - if (!input || !input.length) { - this._frame += 128; - this._silentFrames += 1; - return true; - } - - const channelData = input[0]; - if (!channelData) { - this._frame += 128; - this._silentFrames += 1; - return true; - } - - let peak = this._peak; - let sumSquares = 0; - let clipped = this._clipped; - - for (let i = 0; i < channelData.length; i++) { - const sample = channelData[i]; - const absSample = Math.abs(sample); - if (absSample > peak) { - peak = absSample; - } - if (absSample >= 0.89) { - clipped += 1; - } - sumSquares += sample * sample; - } - - this._frame += channelData.length; - this._peak = peak; - this._clipped = clipped; - this._rms += sumSquares; - - if (this._frame >= this._updateInterval) { - const rms = Math.sqrt(this._rms / this._frame); - const payload = { - peak, - rms, - clipped, - silent: rms <= 0.001, - timestamp: currentTime, - }; - this.port.postMessage(payload); - this._peak = 0; - this._rms = 0; - this._clipped = 0; - this._frame = 0; - this._silentFrames = payload.silent ? this._silentFrames + 1 : 0; - } - - return true; - } -} - -registerProcessor('podcast-meter', MeterProcessor); +class MeterProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._peak = 0; + this._rms = 0; + this._clipped = 0; + this._frame = 0; + this._silentFrames = 0; + this._updateInterval = Math.round(sampleRate * 0.032); + } + + process(inputs) { + const input = inputs[0]; + if (!input || !input.length) { + this._frame += 128; + this._silentFrames += 1; + return true; + } + + const channelData = input[0]; + if (!channelData) { + this._frame += 128; + this._silentFrames += 1; + return true; + } + + let peak = this._peak; + let sumSquares = 0; + let clipped = this._clipped; + + for (let i = 0; i < channelData.length; i++) { + const sample = channelData[i]; + const absSample = Math.abs(sample); + if (absSample > peak) { + peak = absSample; + } + if (absSample >= 0.89) { + clipped += 1; + } + sumSquares += sample * sample; + } + + this._frame += channelData.length; + this._peak = peak; + this._clipped = clipped; + this._rms += sumSquares; + + if (this._frame >= this._updateInterval) { + const rms = Math.sqrt(this._rms / this._frame); + const payload = { + peak, + rms, + clipped, + silent: rms <= 0.001, + timestamp: currentTime, + }; + this.port.postMessage(payload); + this._peak = 0; + this._rms = 0; + this._clipped = 0; + this._frame = 0; + this._silentFrames = payload.silent ? this._silentFrames + 1 : 0; + } + + return true; + } +} + +registerProcessor('podcast-meter', MeterProcessor); diff --git a/core/audio/meters.js b/core/audio/meters.js index 531724a..2ac82b4 100644 --- a/core/audio/meters.js +++ b/core/audio/meters.js @@ -1,83 +1,83 @@ -import { levelBus } from '../events/level-bus.js'; - -const loadedWorklets = new WeakSet(); -const DEFAULT_WORKLET_URL = new URL('./meter.worklet.js', import.meta.url).toString(); - -async function ensureWorkletModule(audioContext, workletUrl = DEFAULT_WORKLET_URL) { - if (loadedWorklets.has(audioContext)) { - return; - } - await audioContext.audioWorklet.addModule(workletUrl); - loadedWorklets.add(audioContext); -} - -function createSource(audioContext, track) { - if (window.MediaStreamTrackAudioSourceNode) { - try { - return new MediaStreamTrackAudioSourceNode(audioContext, { track }); - } catch (error) { - console.warn('Falling back to MediaStream source', error); - } - } - const stream = new MediaStream([track]); - return audioContext.createMediaStreamSource(stream); -} - -const DEFAULT_ANALYSER_OPTIONS = { - fftSize: 512, - minDecibels: -110, - maxDecibels: -10, - smoothingTimeConstant: 0.75, -}; - -export async function monitorTrackLevel(audioContext, track, { uuid, trackType = 'audio', metadata = {} } = {}) { - if (!track || track.kind !== 'audio') { - throw new Error('monitorTrackLevel expects an audio MediaStreamTrack.'); - } - await ensureWorkletModule(audioContext); - const source = createSource(audioContext, track); - const workletNode = new AudioWorkletNode(audioContext, 'podcast-meter'); - const analyser = audioContext.createAnalyser(); - analyser.fftSize = DEFAULT_ANALYSER_OPTIONS.fftSize; - analyser.minDecibels = DEFAULT_ANALYSER_OPTIONS.minDecibels; - analyser.maxDecibels = DEFAULT_ANALYSER_OPTIONS.maxDecibels; - analyser.smoothingTimeConstant = DEFAULT_ANALYSER_OPTIONS.smoothingTimeConstant; - const silentSink = new GainNode(audioContext, { gain: 0 }); - source.connect(workletNode); - source.connect(analyser); - workletNode.connect(silentSink); - analyser.connect(silentSink); - silentSink.connect(audioContext.destination); - workletNode.port.onmessage = (event) => { - levelBus.publishLevel?.({ - uuid, - trackType, - metadata, - ...event.data, - }); - }; - return { - node: workletNode, - analyser, - source, - sink: silentSink, - disconnect: (options = {}) => { - try { - workletNode.port.onmessage = null; - workletNode.disconnect(); - analyser.disconnect(); - silentSink.disconnect(); - } catch (error) { - console.warn('Failed to disconnect meter worklet node', error); - } - try { - source.disconnect(); - } catch (error) { - console.warn('Failed to disconnect meter source', error); - } - if (options.stopTrack) { - track.stop(); - } - }, - }; -} +import { levelBus } from '../events/level-bus.js'; + +const loadedWorklets = new WeakSet(); +const DEFAULT_WORKLET_URL = new URL('./meter.worklet.js', import.meta.url).toString(); + +async function ensureWorkletModule(audioContext, workletUrl = DEFAULT_WORKLET_URL) { + if (loadedWorklets.has(audioContext)) { + return; + } + await audioContext.audioWorklet.addModule(workletUrl); + loadedWorklets.add(audioContext); +} + +function createSource(audioContext, track) { + if (window.MediaStreamTrackAudioSourceNode) { + try { + return new MediaStreamTrackAudioSourceNode(audioContext, { track }); + } catch (error) { + console.warn('Falling back to MediaStream source', error); + } + } + const stream = new MediaStream([track]); + return audioContext.createMediaStreamSource(stream); +} + +const DEFAULT_ANALYSER_OPTIONS = { + fftSize: 512, + minDecibels: -110, + maxDecibels: -10, + smoothingTimeConstant: 0.75, +}; + +export async function monitorTrackLevel(audioContext, track, { uuid, trackType = 'audio', metadata = {} } = {}) { + if (!track || track.kind !== 'audio') { + throw new Error('monitorTrackLevel expects an audio MediaStreamTrack.'); + } + await ensureWorkletModule(audioContext); + const source = createSource(audioContext, track); + const workletNode = new AudioWorkletNode(audioContext, 'podcast-meter'); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = DEFAULT_ANALYSER_OPTIONS.fftSize; + analyser.minDecibels = DEFAULT_ANALYSER_OPTIONS.minDecibels; + analyser.maxDecibels = DEFAULT_ANALYSER_OPTIONS.maxDecibels; + analyser.smoothingTimeConstant = DEFAULT_ANALYSER_OPTIONS.smoothingTimeConstant; + const silentSink = new GainNode(audioContext, { gain: 0 }); + source.connect(workletNode); + source.connect(analyser); + workletNode.connect(silentSink); + analyser.connect(silentSink); + silentSink.connect(audioContext.destination); + workletNode.port.onmessage = (event) => { + levelBus.publishLevel?.({ + uuid, + trackType, + metadata, + ...event.data, + }); + }; + return { + node: workletNode, + analyser, + source, + sink: silentSink, + disconnect: (options = {}) => { + try { + workletNode.port.onmessage = null; + workletNode.disconnect(); + analyser.disconnect(); + silentSink.disconnect(); + } catch (error) { + console.warn('Failed to disconnect meter worklet node', error); + } + try { + source.disconnect(); + } catch (error) { + console.warn('Failed to disconnect meter source', error); + } + if (options.stopTrack) { + track.stop(); + } + }, + }; +} diff --git a/core/events/event-bus.js b/core/events/event-bus.js index 0c35be4..85daed5 100644 --- a/core/events/event-bus.js +++ b/core/events/event-bus.js @@ -1,14 +1,14 @@ -export class EventBus extends EventTarget { - emit(type, detail = {}) { - const event = new CustomEvent(type, { detail }); - this.dispatchEvent(event); - } - - on(type, callback, options) { - const handler = (event) => callback(event.detail, event); - this.addEventListener(type, handler, options); - return () => this.removeEventListener(type, handler, options); - } -} - -export const createScopedBus = () => new EventBus(); +export class EventBus extends EventTarget { + emit(type, detail = {}) { + const event = new CustomEvent(type, { detail }); + this.dispatchEvent(event); + } + + on(type, callback, options) { + const handler = (event) => callback(event.detail, event); + this.addEventListener(type, handler, options); + return () => this.removeEventListener(type, handler, options); + } +} + +export const createScopedBus = () => new EventBus(); diff --git a/core/events/level-bus.js b/core/events/level-bus.js index 7794d53..ce95ef7 100644 --- a/core/events/level-bus.js +++ b/core/events/level-bus.js @@ -1,19 +1,19 @@ -import { EventBus } from './event-bus.js'; - -export const LEVEL_EVENT = 'level'; -export const CLIP_EVENT = 'clip'; -export const SILENCE_EVENT = 'silence'; - -class LevelBus extends EventBus { - publishLevel(payload) { - this.emit(LEVEL_EVENT, payload); - if (payload?.clipped) { - this.emit(CLIP_EVENT, payload); - } - if (payload?.silent) { - this.emit(SILENCE_EVENT, payload); - } - } -} - -export const levelBus = new LevelBus(); +import { EventBus } from './event-bus.js'; + +export const LEVEL_EVENT = 'level'; +export const CLIP_EVENT = 'clip'; +export const SILENCE_EVENT = 'silence'; + +class LevelBus extends EventBus { + publishLevel(payload) { + this.emit(LEVEL_EVENT, payload); + if (payload?.clipped) { + this.emit(CLIP_EVENT, payload); + } + if (payload?.silent) { + this.emit(SILENCE_EVENT, payload); + } + } +} + +export const levelBus = new LevelBus(); diff --git a/core/index.js b/core/index.js index f7a76e8..831088c 100644 --- a/core/index.js +++ b/core/index.js @@ -1,7 +1,7 @@ -export * from './events/event-bus.js'; -export * from './events/level-bus.js'; -export * from './legacy/session-bridge.js'; -export { bridgeLegacyMeters } from './legacy/meter-bridge.js'; -export * from './recording/index.js'; -export * from './audio/meters.js'; -export * from './uploads/index.js'; +export * from './events/event-bus.js'; +export * from './events/level-bus.js'; +export * from './legacy/session-bridge.js'; +export { bridgeLegacyMeters } from './legacy/meter-bridge.js'; +export * from './recording/index.js'; +export * from './audio/meters.js'; +export * from './uploads/index.js'; diff --git a/core/legacy/meter-bridge.js b/core/legacy/meter-bridge.js index a52f652..5c8a1df 100644 --- a/core/legacy/meter-bridge.js +++ b/core/legacy/meter-bridge.js @@ -1,75 +1,75 @@ -import { waitForLegacySession } from './session-bridge.js'; -import { levelBus } from '../events/level-bus.js'; - -const DEFAULT_INTERVAL_MS = 120; -const SILENCE_THRESHOLD = 2; // matches legacy behaviour where values < 2 are ignored - -function normaliseLoudness(value) { - if (typeof value !== 'number' || Number.isNaN(value)) { - return null; - } - const constrained = Math.max(0, value); - const peak = Math.min(1, constrained / 120); - const rms = Math.min(1, constrained / 160); - return { peak, rms, raw: constrained }; -} - -export async function bridgeLegacyMeters(options = {}) { - const { intervalMs = DEFAULT_INTERVAL_MS } = options; - const session = await waitForLegacySession({ timeoutMs: 15000 }); - const lastValues = new Map(); - - function publish(uuid, loudness, metadata) { - if (typeof loudness !== 'number' || loudness < SILENCE_THRESHOLD) { - return; - } - const key = `${uuid}`; - if (lastValues.get(key) === loudness) { - return; - } - lastValues.set(key, loudness); - const values = normaliseLoudness(loudness); - if (!values) { - return; - } - levelBus.publishLevel({ - uuid, - trackType: 'audio', - peak: values.peak, - rms: values.rms, - raw: values.raw, - source: 'legacy-meter', - timestamp: performance.now(), - metadata, - }); - } - - function poll() { - if (session.stats && typeof session.stats.Audio_Loudness === 'number') { - publish('local', session.stats.Audio_Loudness, { kind: 'local' }); - } - - Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => { - if (!peer || !peer.stats) { - return; - } - const loudness = peer.stats.Audio_Loudness; - if (typeof loudness !== 'number') { - return; - } - publish(uuid, loudness, { - kind: 'remote', - streamID: peer.streamID, - label: peer.label, - }); - }); - } - - const timer = setInterval(poll, intervalMs); - poll(); - - return () => { - clearInterval(timer); - lastValues.clear(); - }; -} +import { waitForLegacySession } from './session-bridge.js'; +import { levelBus } from '../events/level-bus.js'; + +const DEFAULT_INTERVAL_MS = 120; +const SILENCE_THRESHOLD = 2; // matches legacy behaviour where values < 2 are ignored + +function normaliseLoudness(value) { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + const constrained = Math.max(0, value); + const peak = Math.min(1, constrained / 120); + const rms = Math.min(1, constrained / 160); + return { peak, rms, raw: constrained }; +} + +export async function bridgeLegacyMeters(options = {}) { + const { intervalMs = DEFAULT_INTERVAL_MS } = options; + const session = await waitForLegacySession({ timeoutMs: 15000 }); + const lastValues = new Map(); + + function publish(uuid, loudness, metadata) { + if (typeof loudness !== 'number' || loudness < SILENCE_THRESHOLD) { + return; + } + const key = `${uuid}`; + if (lastValues.get(key) === loudness) { + return; + } + lastValues.set(key, loudness); + const values = normaliseLoudness(loudness); + if (!values) { + return; + } + levelBus.publishLevel({ + uuid, + trackType: 'audio', + peak: values.peak, + rms: values.rms, + raw: values.raw, + source: 'legacy-meter', + timestamp: performance.now(), + metadata, + }); + } + + function poll() { + if (session.stats && typeof session.stats.Audio_Loudness === 'number') { + publish('local', session.stats.Audio_Loudness, { kind: 'local' }); + } + + Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => { + if (!peer || !peer.stats) { + return; + } + const loudness = peer.stats.Audio_Loudness; + if (typeof loudness !== 'number') { + return; + } + publish(uuid, loudness, { + kind: 'remote', + streamID: peer.streamID, + label: peer.label, + }); + }); + } + + const timer = setInterval(poll, intervalMs); + poll(); + + return () => { + clearInterval(timer); + lastValues.clear(); + }; +} diff --git a/core/legacy/session-bridge.js b/core/legacy/session-bridge.js index 45e00ef..32f4bdb 100644 --- a/core/legacy/session-bridge.js +++ b/core/legacy/session-bridge.js @@ -1,58 +1,58 @@ -const SESSION_POLL_MS = 25; - -export function getLegacySession() { - if (typeof window === 'undefined') { - throw new Error('Session bridge requires a browser context.'); - } - if (!window.session) { - throw new Error('Legacy session object is not initialised yet.'); - } - return window.session; -} - -export async function waitForLegacySession(options = {}) { - const { timeoutMs = 5000 } = options; - const start = performance.now(); - - while (true) { - if (window.session) { - return window.session; - } - if (performance.now() - start > timeoutMs) { - throw new Error('Timed out waiting for legacy session initialisation.'); - } - await new Promise((resolve) => setTimeout(resolve, SESSION_POLL_MS)); - } -} - -export function onLegacyEvent(eventName, handler) { - const session = getLegacySession(); - if (!session._podcastStudioListeners) { - session._podcastStudioListeners = new Map(); - } - if (!session._podcastStudioListeners.has(eventName)) { - session._podcastStudioListeners.set(eventName, new Set()); - } - const listeners = session._podcastStudioListeners.get(eventName); - listeners.add(handler); - - return () => { - listeners.delete(handler); - }; -} - -// shim to forward events from legacy dispatchers -export function forwardLegacyEvent(eventName, payload) { - const session = getLegacySession(); - const listeners = session._podcastStudioListeners?.get(eventName); - if (!listeners) { - return; - } - listeners.forEach((fn) => { - try { - fn(payload); - } catch (error) { - console.error('Legacy event listener failed', error); - } - }); -} +const SESSION_POLL_MS = 25; + +export function getLegacySession() { + if (typeof window === 'undefined') { + throw new Error('Session bridge requires a browser context.'); + } + if (!window.session) { + throw new Error('Legacy session object is not initialised yet.'); + } + return window.session; +} + +export async function waitForLegacySession(options = {}) { + const { timeoutMs = 5000 } = options; + const start = performance.now(); + + while (true) { + if (window.session) { + return window.session; + } + if (performance.now() - start > timeoutMs) { + throw new Error('Timed out waiting for legacy session initialisation.'); + } + await new Promise((resolve) => setTimeout(resolve, SESSION_POLL_MS)); + } +} + +export function onLegacyEvent(eventName, handler) { + const session = getLegacySession(); + if (!session._podcastStudioListeners) { + session._podcastStudioListeners = new Map(); + } + if (!session._podcastStudioListeners.has(eventName)) { + session._podcastStudioListeners.set(eventName, new Set()); + } + const listeners = session._podcastStudioListeners.get(eventName); + listeners.add(handler); + + return () => { + listeners.delete(handler); + }; +} + +// shim to forward events from legacy dispatchers +export function forwardLegacyEvent(eventName, payload) { + const session = getLegacySession(); + const listeners = session._podcastStudioListeners?.get(eventName); + if (!listeners) { + return; + } + listeners.forEach((fn) => { + try { + fn(payload); + } catch (error) { + console.error('Legacy event listener failed', error); + } + }); +} diff --git a/core/recording/index.js b/core/recording/index.js index 2a1e03b..2ecc582 100644 --- a/core/recording/index.js +++ b/core/recording/index.js @@ -1,3 +1,3 @@ -export { MultiTrackRecorder } from './multitrack-recorder.js'; -export { TrackRecorder } from './track-recorder.js'; -export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js'; +export { MultiTrackRecorder } from './multitrack-recorder.js'; +export { TrackRecorder } from './track-recorder.js'; +export { convertBlobToWav, audioBufferToWav } from './wav-encoder.js'; diff --git a/core/recording/multitrack-recorder.js b/core/recording/multitrack-recorder.js index efc1852..d98bdbc 100644 --- a/core/recording/multitrack-recorder.js +++ b/core/recording/multitrack-recorder.js @@ -1,398 +1,440 @@ -import { waitForLegacySession } from '../legacy/session-bridge.js'; -import { monitorTrackLevel } from '../audio/meters.js'; -import { TrackRecorder } from './track-recorder.js'; -import { convertBlobToWav } from './wav-encoder.js'; - -const DEFAULT_OPTIONS = { - includeLocal: true, - includeRemotes: true, - includeScreenshares: false, - includeVideo: false, - mimeType: null, - timeslice: 0, - bitsPerSecond: null, - monitorLevels: true, - audioContext: null, - targetSampleRate: 48000, - filenamePrefix: 'podcast', -}; - -function sanitizeSegment(value, fallback = 'track') { - if (!value) { - return fallback; - } - return String(value) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || fallback; -} - -function extensionFromMime(mimeType) { - if (!mimeType) { - return 'bin'; - } - if (mimeType.includes('wav')) { - return 'wav'; - } - if (mimeType.includes('webm')) { - return 'webm'; - } - if (mimeType.includes('ogg')) { - return 'ogg'; - } - if (mimeType.includes('mp4')) { - return 'mp4'; - } - if (mimeType.includes('m4a')) { - return 'm4a'; - } - return 'bin'; -} - -function buildTimestamp(epoch = Date.now()) { - return new Date(epoch).toISOString().replace(/[:.]/g, '-'); -} - -function safeCloneTrack(track) { - try { - return track.clone(); - } catch (error) { - console.warn('Unable to clone track, using original reference', error); - return track; - } -} - -function gatherTracksFromStream(stream, { includeVideo }) { - if (!stream) { - return { audio: [], video: [] }; - } - const audio = stream.getAudioTracks ? stream.getAudioTracks() : []; - const video = includeVideo && stream.getVideoTracks ? stream.getVideoTracks() : []; - return { - audio: audio.filter(Boolean), - video: video.filter(Boolean), - }; -} - -export class MultiTrackRecorder extends EventTarget { - constructor(options = {}) { - super(); - this.options = { ...DEFAULT_OPTIONS, ...options }; - this.sessionPromise = null; - this.session = null; - this.recorders = new Map(); - this.files = new Map(); - this.trackMeters = new Map(); - this.startedAt = null; - } - - async ensureSession() { - if (this.session) { - return this.session; - } - if (!this.sessionPromise) { - this.sessionPromise = waitForLegacySession(); - } - this.session = await this.sessionPromise; - return this.session; - } - - async listRecordableParticipants() { - const session = await this.ensureSession(); - const participants = []; - - if (this.options.includeLocal && session.streamSrc) { - participants.push({ - uuid: 'local', - kind: 'local', - label: session.label || 'Host', - stream: session.streamSrc, - streamID: session.streamID, - }); - } - - if (this.options.includeRemotes && session.rpcs) { - Object.entries(session.rpcs).forEach(([uuid, peer]) => { - if (!peer) { - return; - } - const label = peer.label || peer.streamID || uuid; - const stream = peer.streamSrc || peer.stream || peer.videoElement?.srcObject; - if (!stream) { - return; - } - participants.push({ - uuid, - kind: 'remote', - label, - stream, - streamID: peer.streamID, - }); - - if (this.options.includeScreenshares && peer.screenShareStream) { - participants.push({ - uuid: `${uuid}:screen`, - kind: 'screenshare', - label: `${label} (Screen)`, - stream: peer.screenShareStream, - streamID: peer.streamID ? `${peer.streamID}:screen` : `${uuid}:screen`, - }); - } - }); - } - - return participants; - } - - createTrackRecorders(participant, options) { - const { includeVideo, mimeType, bitsPerSecond, timeslice, monitorLevels, audioContext } = options; - const { audio, video } = gatherTracksFromStream(participant.stream, { includeVideo }); - const recorders = []; - - audio.forEach((track, index) => { - const cloned = safeCloneTrack(track); - const recorder = new TrackRecorder({ - track: cloned, - uuid: participant.uuid, - label: `${participant.label || participant.uuid}#${index + 1}`, - kind: 'audio', - mimeType, - }); - recorder.start({ timeslice, bitsPerSecond }); - recorders.push({ recorder, trackType: 'audio', channelIndex: index }); - if (monitorLevels && audioContext) { - monitorTrackLevel(audioContext, cloned, { - uuid: participant.uuid, - trackType: 'audio', - metadata: { channelIndex: index, label: participant.label }, - }) - .then((meter) => { - const meterKey = `${participant.uuid}:audio:${index}`; - this.trackMeters.set(meterKey, meter); - this.dispatchEvent( - new CustomEvent('meter-ready', { - detail: { - participant, - trackType: 'audio', - channelIndex: index, - meter, - }, - }), - ); - }) - .catch((error) => { - console.warn('Failed to attach meter to track', error); - }); - } - }); - - video.forEach((track, index) => { - const cloned = safeCloneTrack(track); - const recorder = new TrackRecorder({ - track: cloned, - uuid: participant.uuid, - label: `${participant.label || participant.uuid}-video#${index + 1}`, - kind: 'video', - mimeType: null, - }); - recorder.start({ timeslice, bitsPerSecond }); - recorders.push({ recorder, trackType: 'video', channelIndex: index }); - }); - - return recorders; - } - - async start(customOptions = {}) { - const options = { ...this.options, ...customOptions }; - this.options = options; - this.files.clear(); - this.startedAt = Date.now(); - - if (this.recorders.size) { - throw new Error('MultiTrackRecorder already running.'); - } - - const participants = await this.listRecordableParticipants(); - const extras = Array.isArray(options.extraParticipants) - ? options.extraParticipants - .map((participant, index) => { - if (!participant || !participant.stream) { - return null; - } - const uuid = participant.uuid || `external-${index}`; - return { - uuid, - kind: participant.kind || 'external', - label: participant.label || uuid, - stream: participant.stream, - streamID: participant.streamID || uuid, - external: true, - }; - }) - .filter(Boolean) - : []; - const participantLookup = new Map(); - participants.forEach((participant) => { - if (participant && participant.uuid) { - participantLookup.set(participant.uuid, participant); - } - }); - extras.forEach((participant) => { - if (!participantLookup.has(participant.uuid)) { - participants.push(participant); - participantLookup.set(participant.uuid, participant); - } - }); - - if (!participants.length) { - throw new Error('No recordable participants found.'); - } - - participants.forEach((participant) => { - const recorders = this.createTrackRecorders(participant, options); - recorders.forEach(({ recorder, trackType, channelIndex }) => { - const key = `${participant.uuid}:${trackType}:${channelIndex}`; - this.recorders.set(key, recorder); - recorder.addEventListener('data', (event) => { - this.dispatchEvent( - new CustomEvent('chunk', { - detail: { - participant, - trackType, - channelIndex, - data: event.detail, - }, - }), - ); - }); - recorder.addEventListener('error', (event) => { - this.dispatchEvent( - new CustomEvent('error', { - detail: { - participant, - trackType, - channelIndex, - error: event.detail, - }, - }), - ); - }); - recorder.addEventListener('stop', () => { - const blob = recorder.toBlob(); - if (blob) { - const fileKey = `${participant.uuid}:${trackType}:${channelIndex}`; - this.files.set(fileKey, { - blob, - originalBlob: blob, - participant, - trackType, - channelIndex, - mimeType: recorder.mimeType, - originalMimeType: recorder.mimeType, - durationSeconds: typeof recorder.getDurationSeconds === 'function' ? recorder.getDurationSeconds() : null, - recorderLabel: recorder.label, - }); - } - this.dispatchEvent( - new CustomEvent('track-stopped', { - detail: { - participant, - trackType, - channelIndex, - }, - }), - ); - }); - }); - }); - - this.dispatchEvent(new CustomEvent('start', { detail: { participants } })); - } - - async stop() { - const stops = []; - this.recorders.forEach((recorder) => { - stops.push(recorder.stop()); - }); - this.recorders.clear(); - const meterStops = []; - this.trackMeters.forEach((meter) => { - if (meter && typeof meter.disconnect === 'function') { - meterStops.push(Promise.resolve().then(() => meter.disconnect())); - } - }); - this.trackMeters.clear(); - await Promise.allSettled(stops); - await Promise.allSettled(meterStops); - await this.packageAudioFiles(); - const packaged = this.files; - this.dispatchEvent(new CustomEvent('stop', { detail: { files: packaged } })); - return packaged; - } - - getFiles() { - return this.files; - } - - getTrackMeter(uuid, trackType = 'audio', channelIndex = 0) { - if (!uuid) { - return null; - } - const key = `${uuid}:${trackType}:${channelIndex}`; - return this.trackMeters.get(key) || null; - } - - async packageAudioFiles() { - if (!this.files.size) { - return this.files; - } - const conversions = []; - this.files.forEach((meta, key) => { - const fileMeta = meta; - if (!fileMeta.filename) { - fileMeta.filename = this.generateFilename(fileMeta); - } - if (fileMeta.trackType !== 'audio' || !fileMeta.blob) { - // For non-audio tracks we still normalise mime type/filename - fileMeta.mimeType = fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type || 'application/octet-stream'; - fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0; - return; - } - conversions.push( - (async () => { - try { - const wavBlob = await convertBlobToWav(fileMeta.blob, { - sampleRate: this.options.targetSampleRate, - audioContext: this.options.audioContext, - }); - fileMeta.originalBlob = fileMeta.originalBlob || fileMeta.blob; - fileMeta.originalMimeType = fileMeta.originalMimeType || fileMeta.mimeType; - fileMeta.blob = wavBlob; - fileMeta.mimeType = 'audio/wav'; - fileMeta.filename = this.generateFilename(fileMeta, { extension: 'wav' }); - fileMeta.size = wavBlob.size; - } catch (error) { - console.warn('Failed to package track as WAV, falling back to original blob', error); - const extension = extensionFromMime(fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type); - fileMeta.filename = this.generateFilename(fileMeta, { extension }); - fileMeta.packagingError = error; - fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0; - } - })(), - ); - }); - await Promise.allSettled(conversions); - return this.files; - } - - generateFilename(meta, { extension } = {}) { - const prefix = sanitizeSegment(this.options.filenamePrefix, 'podcast'); - const room = sanitizeSegment(this.session?.roomid || this.session?.roomID || 'room'); - const participantLabel = sanitizeSegment(meta.participant?.streamID || meta.participant?.label || meta.participant?.uuid, meta.participant?.uuid || 'participant'); - const trackKind = sanitizeSegment(meta.trackType || 'track'); - const channel = typeof meta.channelIndex === 'number' ? `c${meta.channelIndex + 1}` : 'c1'; - const timestamp = buildTimestamp(this.startedAt); - const ext = extension || extensionFromMime(meta.mimeType || meta.originalMimeType || 'audio/wav'); - return `${prefix}-${room}-${participantLabel}-${trackKind}-${channel}-${timestamp}.${ext}`; - } -} +import { waitForLegacySession } from '../legacy/session-bridge.js'; +import { monitorTrackLevel } from '../audio/meters.js'; +import { TrackRecorder } from './track-recorder.js'; +import { convertBlobToWav } from './wav-encoder.js'; + +const DEFAULT_OPTIONS = { + includeLocal: true, + includeRemotes: true, + includeScreenshares: false, + includeVideo: false, + mimeType: null, + timeslice: 0, + bitsPerSecond: null, + monitorLevels: true, + audioContext: null, + targetSampleRate: 48000, + filenamePrefix: 'podcast', +}; + +function sanitizeSegment(value, fallback = 'track') { + if (!value) { + return fallback; + } + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || fallback; +} + +function extensionFromMime(mimeType) { + if (!mimeType) { + return 'bin'; + } + if (mimeType.includes('wav')) { + return 'wav'; + } + if (mimeType.includes('webm')) { + return 'webm'; + } + if (mimeType.includes('ogg')) { + return 'ogg'; + } + if (mimeType.includes('mp4')) { + return 'mp4'; + } + if (mimeType.includes('m4a')) { + return 'm4a'; + } + return 'bin'; +} + +function buildTimestamp(epoch = Date.now()) { + return new Date(epoch).toISOString().replace(/[:.]/g, '-'); +} + +function safeCloneTrack(track) { + try { + return track.clone(); + } catch (error) { + console.warn('Unable to clone track, using original reference', error); + return track; + } +} + +function gatherTracksFromStream(stream, { includeVideo }) { + if (!stream) { + return { audio: [], video: [] }; + } + const audio = stream.getAudioTracks ? stream.getAudioTracks() : []; + const video = includeVideo && stream.getVideoTracks ? stream.getVideoTracks() : []; + return { + audio: audio.filter(Boolean), + video: video.filter(Boolean), + }; +} + +export class MultiTrackRecorder extends EventTarget { + constructor(options = {}) { + super(); + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.sessionPromise = null; + this.session = null; + this.recorders = new Map(); + this.files = new Map(); + this.trackMeters = new Map(); + this.trackStartTimes = new Map(); // Per-track start times relative to session start + this.startedAt = null; + } + + async ensureSession() { + if (this.session) { + return this.session; + } + if (!this.sessionPromise) { + this.sessionPromise = waitForLegacySession(); + } + this.session = await this.sessionPromise; + return this.session; + } + + async listRecordableParticipants() { + const session = await this.ensureSession(); + const participants = []; + + if (this.options.includeLocal && session.streamSrc) { + participants.push({ + uuid: 'local', + kind: 'local', + label: session.label || 'Host', + stream: session.streamSrc, + streamID: session.streamID, + }); + } + + if (this.options.includeRemotes && session.rpcs) { + Object.entries(session.rpcs).forEach(([uuid, peer]) => { + if (!peer) { + return; + } + const label = peer.label || peer.streamID || uuid; + const stream = peer.streamSrc || peer.stream || peer.videoElement?.srcObject; + if (!stream) { + return; + } + participants.push({ + uuid, + kind: 'remote', + label, + stream, + streamID: peer.streamID, + }); + + if (this.options.includeScreenshares && peer.screenShareStream) { + participants.push({ + uuid: `${uuid}:screen`, + kind: 'screenshare', + label: `${label} (Screen)`, + stream: peer.screenShareStream, + streamID: peer.streamID ? `${peer.streamID}:screen` : `${uuid}:screen`, + }); + } + }); + } + + return participants; + } + + createTrackRecorders(participant, options, startOffsetSeconds = 0) { + const { includeVideo, mimeType, bitsPerSecond, timeslice, monitorLevels, audioContext } = options; + const { audio, video } = gatherTracksFromStream(participant.stream, { includeVideo }); + const recorders = []; + const trackStartOffset = Number.isFinite(startOffsetSeconds) ? Math.max(0, startOffsetSeconds) : 0; + + audio.forEach((track, index) => { + const cloned = safeCloneTrack(track); + const recorder = new TrackRecorder({ + track: cloned, + uuid: participant.uuid, + label: `${participant.label || participant.uuid}#${index + 1}`, + kind: 'audio', + mimeType, + }); + recorder.start({ timeslice, bitsPerSecond }); + recorders.push({ recorder, trackType: 'audio', channelIndex: index, startOffsetSeconds: trackStartOffset }); + if (monitorLevels && audioContext) { + monitorTrackLevel(audioContext, cloned, { + uuid: participant.uuid, + trackType: 'audio', + metadata: { channelIndex: index, label: participant.label }, + }) + .then((meter) => { + const meterKey = `${participant.uuid}:audio:${index}`; + this.trackMeters.set(meterKey, meter); + this.dispatchEvent( + new CustomEvent('meter-ready', { + detail: { + participant, + trackType: 'audio', + channelIndex: index, + meter, + }, + }), + ); + }) + .catch((error) => { + console.warn('Failed to attach meter to track', error); + }); + } + }); + + video.forEach((track, index) => { + const cloned = safeCloneTrack(track); + const recorder = new TrackRecorder({ + track: cloned, + uuid: participant.uuid, + label: `${participant.label || participant.uuid}-video#${index + 1}`, + kind: 'video', + mimeType: null, + }); + recorder.start({ timeslice, bitsPerSecond }); + recorders.push({ recorder, trackType: 'video', channelIndex: index, startOffsetSeconds: trackStartOffset }); + }); + + return recorders; + } + + attachRecorderHandlers(participant, recorders) { + recorders.forEach(({ recorder, trackType, channelIndex, startOffsetSeconds }) => { + const key = `${participant.uuid}:${trackType}:${channelIndex}`; + this.recorders.set(key, recorder); + this.trackStartTimes.set(key, startOffsetSeconds); + recorder.addEventListener('data', (event) => { + this.dispatchEvent( + new CustomEvent('chunk', { + detail: { + participant, + trackType, + channelIndex, + data: event.detail, + }, + }), + ); + }); + recorder.addEventListener('error', (event) => { + this.dispatchEvent( + new CustomEvent('error', { + detail: { + participant, + trackType, + channelIndex, + error: event.detail, + }, + }), + ); + }); + recorder.addEventListener('stop', () => { + const blob = recorder.toBlob(); + if (blob) { + const fileKey = `${participant.uuid}:${trackType}:${channelIndex}`; + this.files.set(fileKey, { + blob, + originalBlob: blob, + participant, + trackType, + channelIndex, + mimeType: recorder.mimeType, + originalMimeType: recorder.mimeType, + durationSeconds: typeof recorder.getDurationSeconds === 'function' ? recorder.getDurationSeconds() : null, + recorderLabel: recorder.label, + startOffsetSeconds, + }); + } + this.dispatchEvent( + new CustomEvent('track-stopped', { + detail: { + participant, + trackType, + channelIndex, + }, + }), + ); + }); + }); + } + + addParticipant(participant) { + if (!this.startedAt) { + throw new Error('Cannot add participant: recording not started.'); + } + if (!participant || !participant.stream) { + throw new Error('Cannot add participant: missing stream.'); + } + const startOffsetSeconds = (Date.now() - this.startedAt) / 1000; + const recorders = this.createTrackRecorders(participant, this.options, startOffsetSeconds); + if (!recorders.length) { + return { added: false, tracks: 0, startOffsetSeconds }; + } + this.attachRecorderHandlers(participant, recorders); + this.dispatchEvent( + new CustomEvent('participant-added', { + detail: { + participant, + trackCount: recorders.length, + startOffsetSeconds, + }, + }), + ); + return { added: true, tracks: recorders.length, startOffsetSeconds }; + } + + isRecording() { + return this.startedAt !== null && this.recorders.size > 0; + } + + async start(customOptions = {}) { + const options = { ...this.options, ...customOptions }; + this.options = options; + this.files.clear(); + this.startedAt = Date.now(); + + if (this.recorders.size) { + throw new Error('MultiTrackRecorder already running.'); + } + + const participants = await this.listRecordableParticipants(); + const extras = Array.isArray(options.extraParticipants) + ? options.extraParticipants + .map((participant, index) => { + if (!participant || !participant.stream) { + return null; + } + const uuid = participant.uuid || `external-${index}`; + return { + uuid, + kind: participant.kind || 'external', + label: participant.label || uuid, + stream: participant.stream, + streamID: participant.streamID || uuid, + external: true, + }; + }) + .filter(Boolean) + : []; + const participantLookup = new Map(); + participants.forEach((participant) => { + if (participant && participant.uuid) { + participantLookup.set(participant.uuid, participant); + } + }); + extras.forEach((participant) => { + if (!participantLookup.has(participant.uuid)) { + participants.push(participant); + participantLookup.set(participant.uuid, participant); + } + }); + + if (!participants.length) { + throw new Error('No recordable participants found.'); + } + + participants.forEach((participant) => { + const recorders = this.createTrackRecorders(participant, options, 0); + this.attachRecorderHandlers(participant, recorders); + }); + + this.dispatchEvent(new CustomEvent('start', { detail: { participants, startedAt: this.startedAt } })); + } + + async stop({ markers = null } = {}) { + const stops = []; + this.recorders.forEach((recorder) => { + stops.push(recorder.stop()); + }); + this.recorders.clear(); + const meterStops = []; + this.trackMeters.forEach((meter) => { + if (meter && typeof meter.disconnect === 'function') { + meterStops.push(Promise.resolve().then(() => meter.disconnect())); + } + }); + this.trackMeters.clear(); + this.trackStartTimes.clear(); + await Promise.allSettled(stops); + await Promise.allSettled(meterStops); + await this.packageAudioFiles({ markers }); + const packaged = this.files; + this.startedAt = null; + this.dispatchEvent(new CustomEvent('stop', { detail: { files: packaged } })); + return packaged; + } + + getFiles() { + return this.files; + } + + getTrackMeter(uuid, trackType = 'audio', channelIndex = 0) { + if (!uuid) { + return null; + } + const key = `${uuid}:${trackType}:${channelIndex}`; + return this.trackMeters.get(key) || null; + } + + async packageAudioFiles({ markers = null } = {}) { + if (!this.files.size) { + return this.files; + } + const conversions = []; + this.files.forEach((meta, key) => { + const fileMeta = meta; + if (!fileMeta.filename) { + fileMeta.filename = this.generateFilename(fileMeta); + } + if (fileMeta.trackType !== 'audio' || !fileMeta.blob) { + // For non-audio tracks we still normalise mime type/filename + fileMeta.mimeType = fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type || 'application/octet-stream'; + fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0; + return; + } + conversions.push( + (async () => { + try { + const trackStartOffsetSeconds = fileMeta.startOffsetSeconds || 0; + const wavBlob = await convertBlobToWav(fileMeta.blob, { + sampleRate: this.options.targetSampleRate, + markers, + trackStartOffsetSeconds, + audioContext: this.options.audioContext, + }); + fileMeta.originalBlob = fileMeta.originalBlob || fileMeta.blob; + fileMeta.originalMimeType = fileMeta.originalMimeType || fileMeta.mimeType; + fileMeta.blob = wavBlob; + fileMeta.mimeType = 'audio/wav'; + fileMeta.filename = this.generateFilename(fileMeta, { extension: 'wav' }); + fileMeta.size = wavBlob.size; + } catch (error) { + console.warn('Failed to package track as WAV, falling back to original blob', error); + const extension = extensionFromMime(fileMeta.mimeType || fileMeta.originalMimeType || fileMeta.blob?.type); + fileMeta.filename = this.generateFilename(fileMeta, { extension }); + fileMeta.packagingError = error; + fileMeta.size = fileMeta.blob?.size || fileMeta.originalBlob?.size || 0; + } + })(), + ); + }); + await Promise.allSettled(conversions); + return this.files; + } + + generateFilename(meta, { extension } = {}) { + const prefix = sanitizeSegment(this.options.filenamePrefix, 'podcast'); + const room = sanitizeSegment(this.session?.roomid || this.session?.roomID || 'room'); + const participantLabel = sanitizeSegment(meta.participant?.streamID || meta.participant?.label || meta.participant?.uuid, meta.participant?.uuid || 'participant'); + const trackKind = sanitizeSegment(meta.trackType || 'track'); + const channel = typeof meta.channelIndex === 'number' ? `c${meta.channelIndex + 1}` : 'c1'; + const timestamp = buildTimestamp(this.startedAt); + const ext = extension || extensionFromMime(meta.mimeType || meta.originalMimeType || 'audio/wav'); + return `${prefix}-${room}-${participantLabel}-${trackKind}-${channel}-${timestamp}.${ext}`; + } +} diff --git a/core/recording/track-recorder.js b/core/recording/track-recorder.js index 6061047..272897c 100644 --- a/core/recording/track-recorder.js +++ b/core/recording/track-recorder.js @@ -1,134 +1,134 @@ -const DEFAULT_AUDIO_MIME_TYPES = [ - 'audio/webm;codecs=opus', - 'audio/ogg;codecs=opus', - 'audio/webm', - 'audio/ogg', -]; - -export class TrackRecorder extends EventTarget { - constructor({ track, uuid, label, kind = 'audio', mimeType }) { - super(); - if (!track) { - throw new Error('TrackRecorder requires a MediaStreamTrack.'); - } - this.track = track; - this.uuid = uuid; - this.label = label; - this.kind = kind; - this.mimeType = mimeType || TrackRecorder.pickSupportedMime(kind); - this.mediaRecorder = null; - this.chunks = []; - this.startedAt = null; - this.stoppedAt = null; - this.stopResolver = null; - this.stopComplete = null; - } - - static pickSupportedMime(kind) { - if (typeof MediaRecorder === 'undefined') { - return null; - } - if (kind === 'video') { - const candidates = [ - 'video/webm;codecs=vp9,opus', - 'video/webm;codecs=vp8,opus', - 'video/webm', - ]; - return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null; - } - return DEFAULT_AUDIO_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || null; - } - - createStream() { - const stream = new MediaStream(); - stream.addTrack(this.track); - return stream; - } - - start(options = {}) { - if (this.mediaRecorder) { - throw new Error('TrackRecorder already started.'); - } - const stream = this.createStream(); - const recorderOptions = {}; - if (this.mimeType) { - recorderOptions.mimeType = this.mimeType; - } - if (options.bitsPerSecond) { - recorderOptions.bitsPerSecond = options.bitsPerSecond; - } - - this.mediaRecorder = new MediaRecorder(stream, recorderOptions); - this.chunks = []; - this.startedAt = performance.now(); - this.stoppedAt = null; - this.stopComplete = new Promise((resolve) => { - this.stopResolver = resolve; - }); - - this.mediaRecorder.ondataavailable = (event) => { - if (event.data && event.data.size > 0) { - this.chunks.push(event.data); - this.dispatchEvent(new CustomEvent('data', { detail: event.data })); - } - }; - - this.mediaRecorder.onstop = (event) => { - this.stoppedAt = performance.now(); - if (this.stopResolver) { - this.stopResolver(); - this.stopResolver = null; - } - this.dispatchEvent(new CustomEvent('stop', { detail: event })); - }; - - this.mediaRecorder.onerror = (event) => { - this.dispatchEvent(new CustomEvent('error', { detail: event.error || event })); - }; - - this.mediaRecorder.start(options.timeslice || 0); - } - - stop() { - if (!this.mediaRecorder) { - return Promise.resolve(); - } - const completion = this.stopComplete || Promise.resolve(); - if (this.mediaRecorder.state !== 'inactive') { - try { - this.mediaRecorder.stop(); - } catch (error) { - console.warn('MediaRecorder stop failed', error); - } - } - return completion - .catch((error) => { - console.warn('MediaRecorder stop did not resolve cleanly', error); - }) - .finally(() => { - try { - this.track.stop(); - } catch (error) { - console.warn('Failed to stop cloned track', error); - } - this.mediaRecorder = null; - this.stopComplete = null; - }); - } - - toBlob() { - if (!this.chunks.length) { - return null; - } - const mimeType = this.mimeType || (this.kind === 'audio' ? 'audio/webm' : 'video/webm'); - return new Blob(this.chunks, { type: mimeType }); - } - - getDurationSeconds() { - if (!this.startedAt) { - return 0; - } - const end = this.stoppedAt || performance.now(); - return (end - this.startedAt) / 1000; - } -} +const DEFAULT_AUDIO_MIME_TYPES = [ + 'audio/webm;codecs=opus', + 'audio/ogg;codecs=opus', + 'audio/webm', + 'audio/ogg', +]; + +export class TrackRecorder extends EventTarget { + constructor({ track, uuid, label, kind = 'audio', mimeType }) { + super(); + if (!track) { + throw new Error('TrackRecorder requires a MediaStreamTrack.'); + } + this.track = track; + this.uuid = uuid; + this.label = label; + this.kind = kind; + this.mimeType = mimeType || TrackRecorder.pickSupportedMime(kind); + this.mediaRecorder = null; + this.chunks = []; + this.startedAt = null; + this.stoppedAt = null; + this.stopResolver = null; + this.stopComplete = null; + } + + static pickSupportedMime(kind) { + if (typeof MediaRecorder === 'undefined') { + return null; + } + if (kind === 'video') { + const candidates = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm', + ]; + return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null; + } + return DEFAULT_AUDIO_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) || null; + } + + createStream() { + const stream = new MediaStream(); + stream.addTrack(this.track); + return stream; + } + + start(options = {}) { + if (this.mediaRecorder) { + throw new Error('TrackRecorder already started.'); + } + const stream = this.createStream(); + const recorderOptions = {}; + if (this.mimeType) { + recorderOptions.mimeType = this.mimeType; + } + if (options.bitsPerSecond) { + recorderOptions.bitsPerSecond = options.bitsPerSecond; + } + + this.mediaRecorder = new MediaRecorder(stream, recorderOptions); + this.chunks = []; + this.startedAt = performance.now(); + this.stoppedAt = null; + this.stopComplete = new Promise((resolve) => { + this.stopResolver = resolve; + }); + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + this.chunks.push(event.data); + this.dispatchEvent(new CustomEvent('data', { detail: event.data })); + } + }; + + this.mediaRecorder.onstop = (event) => { + this.stoppedAt = performance.now(); + if (this.stopResolver) { + this.stopResolver(); + this.stopResolver = null; + } + this.dispatchEvent(new CustomEvent('stop', { detail: event })); + }; + + this.mediaRecorder.onerror = (event) => { + this.dispatchEvent(new CustomEvent('error', { detail: event.error || event })); + }; + + this.mediaRecorder.start(options.timeslice || 0); + } + + stop() { + if (!this.mediaRecorder) { + return Promise.resolve(); + } + const completion = this.stopComplete || Promise.resolve(); + if (this.mediaRecorder.state !== 'inactive') { + try { + this.mediaRecorder.stop(); + } catch (error) { + console.warn('MediaRecorder stop failed', error); + } + } + return completion + .catch((error) => { + console.warn('MediaRecorder stop did not resolve cleanly', error); + }) + .finally(() => { + try { + this.track.stop(); + } catch (error) { + console.warn('Failed to stop cloned track', error); + } + this.mediaRecorder = null; + this.stopComplete = null; + }); + } + + toBlob() { + if (!this.chunks.length) { + return null; + } + const mimeType = this.mimeType || (this.kind === 'audio' ? 'audio/webm' : 'video/webm'); + return new Blob(this.chunks, { type: mimeType }); + } + + getDurationSeconds() { + if (!this.startedAt) { + return 0; + } + const end = this.stoppedAt || performance.now(); + return (end - this.startedAt) / 1000; + } +} diff --git a/core/recording/wav-encoder.js b/core/recording/wav-encoder.js index e22f32f..924aa44 100644 --- a/core/recording/wav-encoder.js +++ b/core/recording/wav-encoder.js @@ -1,140 +1,321 @@ -const DEFAULT_SAMPLE_RATE = 48000; - -function writeString(view, offset, string) { - for (let i = 0; i < string.length; i += 1) { - view.setUint8(offset + i, string.charCodeAt(i)); - } -} - -function interleaveChannels(channelData) { - if (!channelData.length) { - return new Float32Array(); - } - const length = channelData[0].length; - if (channelData.length === 1) { - return new Float32Array(channelData[0]); - } - const interleaved = new Float32Array(length * channelData.length); - let index = 0; - for (let i = 0; i < length; i += 1) { - for (let channel = 0; channel < channelData.length; channel += 1) { - interleaved[index] = channelData[channel][i]; - index += 1; - } - } - return interleaved; -} - -function floatTo16BitPCM(view, offset, input) { - for (let i = 0; i < input.length; i += 1, offset += 2) { - let sample = input[i]; - sample = Math.max(-1, Math.min(1, sample)); - const converted = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; - view.setInt16(offset, converted, true); - } -} - -export function audioBufferToWav(audioBuffer, { float32 = false } = {}) { - if (!audioBuffer) { - throw new Error('audioBufferToWav expects an AudioBuffer.'); - } - const numberOfChannels = audioBuffer.numberOfChannels || 1; - const sampleRate = audioBuffer.sampleRate || DEFAULT_SAMPLE_RATE; - const channelData = []; - for (let i = 0; i < numberOfChannels; i += 1) { - channelData.push(audioBuffer.getChannelData(i)); - } - const interleaved = interleaveChannels(channelData); - const bytesPerSample = float32 ? 4 : 2; - const format = float32 ? 3 : 1; - const dataLength = interleaved.length * bytesPerSample; - const buffer = new ArrayBuffer(44 + dataLength); - const view = new DataView(buffer); - - writeString(view, 0, 'RIFF'); - view.setUint32(4, 36 + dataLength, true); - writeString(view, 8, 'WAVE'); - writeString(view, 12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, format, true); - view.setUint16(22, numberOfChannels, true); - view.setUint32(24, sampleRate, true); - view.setUint32(28, sampleRate * numberOfChannels * bytesPerSample, true); - view.setUint16(32, numberOfChannels * bytesPerSample, true); - view.setUint16(34, bytesPerSample * 8, true); - writeString(view, 36, 'data'); - view.setUint32(40, dataLength, true); - - if (float32) { - const floatView = new Float32Array(buffer, 44, interleaved.length); - floatView.set(interleaved); - } else { - floatTo16BitPCM(view, 44, interleaved); - } - - return buffer; -} - -async function resampleIfNeeded(audioBuffer, targetSampleRate) { - if (!targetSampleRate || !audioBuffer) { - return audioBuffer; - } - if (Math.abs(audioBuffer.sampleRate - targetSampleRate) < 1) { - return audioBuffer; - } - if (typeof OfflineAudioContext === 'undefined') { - return audioBuffer; - } - const length = Math.ceil(audioBuffer.duration * targetSampleRate); - const offline = new OfflineAudioContext(audioBuffer.numberOfChannels, length, targetSampleRate); - const source = offline.createBufferSource(); - source.buffer = audioBuffer; - source.connect(offline.destination); - source.start(0); - return offline.startRendering(); -} - -export async function convertBlobToWav(blob, { sampleRate = DEFAULT_SAMPLE_RATE, float32 = false, audioContext = null } = {}) { - if (!blob) { - throw new Error('convertBlobToWav expects a Blob.'); - } - if (typeof window === 'undefined') { - throw new Error('convertBlobToWav requires a browser environment.'); - } - - const AudioContextCtor = window.AudioContext || window.webkitAudioContext; - if (!audioContext && !AudioContextCtor) { - throw new Error('AudioContext not supported in this environment.'); - } - - const context = audioContext || new AudioContextCtor(); - const shouldCloseContext = !audioContext && typeof context.close === 'function'; - - let audioBuffer; - try { - const arrayBuffer = await blob.arrayBuffer(); - const bufferCopy = arrayBuffer.slice(0); - audioBuffer = await new Promise((resolve, reject) => { - context.decodeAudioData(bufferCopy, resolve, reject); - }); - } catch (error) { - if (shouldCloseContext) { - await context.close(); - } - throw error; - } - - if (shouldCloseContext) { - await context.close(); - } - - let processedBuffer = audioBuffer; - try { - processedBuffer = await resampleIfNeeded(audioBuffer, sampleRate); - } catch (error) { - console.warn('Failed to resample audio buffer, using original sample rate', error); - } - - const wavArrayBuffer = audioBufferToWav(processedBuffer, { float32 }); - return new Blob([wavArrayBuffer], { type: 'audio/wav' }); -} +const DEFAULT_SAMPLE_RATE = 48000; + +function writeString(view, offset, string) { + for (let i = 0; i < string.length; i += 1) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +} + +function padToEven(value) { + if (!Number.isFinite(value)) { + return 0; + } + return value % 2 === 0 ? value : value + 1; +} + +function sanitiseMarkerLabel(label, fallback = 'Marker') { + if (!label) { + return fallback; + } + if (typeof label !== 'string') { + return fallback; + } + return label.replace(/\0/g, '').replace(/\r?\n/g, ' ').trim() || fallback; +} + +function normaliseCueMarkers(markers, sampleRate, maxSampleOffset, trackStartOffsetSeconds = 0) { + if (!Array.isArray(markers) || !markers.length || !Number.isFinite(sampleRate) || sampleRate <= 0) { + return []; + } + + const trackOffset = Number.isFinite(trackStartOffsetSeconds) ? Math.max(0, trackStartOffsetSeconds) : 0; + + const cueMarkers = []; + for (let i = 0; i < markers.length; i += 1) { + const marker = markers[i]; + if (!marker) { + continue; + } + const rawTimeSeconds = + marker.timeSeconds ?? + marker.time ?? + marker.seconds ?? + marker.t ?? + (typeof marker.timestamp === 'number' ? marker.timestamp : undefined); + if (!Number.isFinite(rawTimeSeconds)) { + continue; + } + // Adjust marker time relative to this track's start + const adjustedSeconds = rawTimeSeconds - trackOffset; + // Skip markers that occurred before this track started + if (adjustedSeconds < 0) { + continue; + } + let sampleOffset = Math.round(adjustedSeconds * sampleRate); + if (Number.isFinite(maxSampleOffset)) { + sampleOffset = Math.min(Math.max(0, sampleOffset), maxSampleOffset); + } else { + sampleOffset = Math.max(0, sampleOffset); + } + cueMarkers.push({ + timeSeconds: rawTimeSeconds, + adjustedSeconds, + sampleOffset, + label: sanitiseMarkerLabel(marker.label, `Marker ${cueMarkers.length + 1}`), + }); + } + + cueMarkers.sort((a, b) => a.sampleOffset - b.sampleOffset); + + return cueMarkers.map((marker, index) => ({ + ...marker, + id: index + 1, + })); +} + +function buildCueChunk(cueMarkers) { + if (!cueMarkers.length) { + return null; + } + + const cuePointsSize = 4 + cueMarkers.length * 24; + const cueChunkSize = padToEven(cuePointsSize); + const buffer = new ArrayBuffer(8 + cueChunkSize); + const view = new DataView(buffer); + + writeString(view, 0, 'cue '); + view.setUint32(4, cuePointsSize, true); + view.setUint32(8, cueMarkers.length, true); + + let offset = 12; + cueMarkers.forEach((marker) => { + view.setUint32(offset, marker.id, true); + offset += 4; + view.setUint32(offset, marker.sampleOffset, true); + offset += 4; + writeString(view, offset, 'data'); + offset += 4; + view.setUint32(offset, 0, true); + offset += 4; + view.setUint32(offset, 0, true); + offset += 4; + view.setUint32(offset, marker.sampleOffset, true); + offset += 4; + }); + + return new Uint8Array(buffer); +} + +function buildAdtlListChunk(cueMarkers) { + if (!cueMarkers.length) { + return null; + } + + const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; + if (!encoder) { + return null; + } + + const labels = cueMarkers.map((marker) => { + const labelBytes = encoder.encode(sanitiseMarkerLabel(marker.label, `Marker ${marker.id}`)); + const dataSize = 4 + labelBytes.length + 1; + return { + id: marker.id, + labelBytes, + dataSize, + paddedDataSize: padToEven(dataSize), + }; + }); + + const listDataSize = 4 + labels.reduce((total, entry) => total + 8 + entry.paddedDataSize, 0); + const listChunkSize = padToEven(listDataSize); + const buffer = new ArrayBuffer(8 + listChunkSize); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + writeString(view, 0, 'LIST'); + view.setUint32(4, listDataSize, true); + writeString(view, 8, 'adtl'); + + let offset = 12; + labels.forEach((entry) => { + writeString(view, offset, 'labl'); + offset += 4; + view.setUint32(offset, entry.dataSize, true); + offset += 4; + view.setUint32(offset, entry.id, true); + offset += 4; + bytes.set(entry.labelBytes, offset); + offset += entry.labelBytes.length; + bytes[offset] = 0; + offset += 1; + const padding = entry.paddedDataSize - entry.dataSize; + offset += padding; + }); + + return new Uint8Array(buffer); +} + +function interleaveChannels(channelData) { + if (!channelData.length) { + return new Float32Array(); + } + const length = channelData[0].length; + if (channelData.length === 1) { + return new Float32Array(channelData[0]); + } + const interleaved = new Float32Array(length * channelData.length); + let index = 0; + for (let i = 0; i < length; i += 1) { + for (let channel = 0; channel < channelData.length; channel += 1) { + interleaved[index] = channelData[channel][i]; + index += 1; + } + } + return interleaved; +} + +function floatTo16BitPCM(view, offset, input) { + for (let i = 0; i < input.length; i += 1, offset += 2) { + let sample = input[i]; + sample = Math.max(-1, Math.min(1, sample)); + const converted = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; + view.setInt16(offset, converted, true); + } +} + +export function audioBufferToWav(audioBuffer, { float32 = false, markers = null, trackStartOffsetSeconds = 0 } = {}) { + if (!audioBuffer) { + throw new Error('audioBufferToWav expects an AudioBuffer.'); + } + const numberOfChannels = audioBuffer.numberOfChannels || 1; + const sampleRate = audioBuffer.sampleRate || DEFAULT_SAMPLE_RATE; + const channelData = []; + for (let i = 0; i < numberOfChannels; i += 1) { + channelData.push(audioBuffer.getChannelData(i)); + } + const interleaved = interleaveChannels(channelData); + const bytesPerSample = float32 ? 4 : 2; + const format = float32 ? 3 : 1; + const dataLength = interleaved.length * bytesPerSample; + + const cueMarkers = normaliseCueMarkers(markers, sampleRate, audioBuffer.length ? Math.max(0, audioBuffer.length - 1) : 0, trackStartOffsetSeconds); + const cueChunk = buildCueChunk(cueMarkers); + const listChunk = buildAdtlListChunk(cueMarkers); + + const riffHeaderSize = 12; + const fmtChunkTotal = 8 + 16; + const dataChunkTotal = 8 + padToEven(dataLength); + const cueChunkTotal = cueChunk ? cueChunk.length : 0; + const listChunkTotal = listChunk ? listChunk.length : 0; + + const totalSize = riffHeaderSize + fmtChunkTotal + dataChunkTotal + cueChunkTotal + listChunkTotal; + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + writeString(view, 0, 'RIFF'); + view.setUint32(4, totalSize - 8, true); + writeString(view, 8, 'WAVE'); + + let offset = 12; + + writeString(view, offset, 'fmt '); + view.setUint32(offset + 4, 16, true); + view.setUint16(offset + 8, format, true); + view.setUint16(offset + 10, numberOfChannels, true); + view.setUint32(offset + 12, sampleRate, true); + view.setUint32(offset + 16, sampleRate * numberOfChannels * bytesPerSample, true); + view.setUint16(offset + 20, numberOfChannels * bytesPerSample, true); + view.setUint16(offset + 22, bytesPerSample * 8, true); + offset += fmtChunkTotal; + + writeString(view, offset, 'data'); + view.setUint32(offset + 4, dataLength, true); + const dataOffset = offset + 8; + + if (float32) { + const floatView = new Float32Array(buffer, dataOffset, interleaved.length); + floatView.set(interleaved); + } else { + floatTo16BitPCM(view, dataOffset, interleaved); + } + + offset += 8 + padToEven(dataLength); + + if (cueChunk) { + bytes.set(cueChunk, offset); + offset += cueChunk.length; + } + + if (listChunk) { + bytes.set(listChunk, offset); + offset += listChunk.length; + } + + return buffer; +} + +async function resampleIfNeeded(audioBuffer, targetSampleRate) { + if (!targetSampleRate || !audioBuffer) { + return audioBuffer; + } + if (Math.abs(audioBuffer.sampleRate - targetSampleRate) < 1) { + return audioBuffer; + } + if (typeof OfflineAudioContext === 'undefined') { + return audioBuffer; + } + const length = Math.ceil(audioBuffer.duration * targetSampleRate); + const offline = new OfflineAudioContext(audioBuffer.numberOfChannels, length, targetSampleRate); + const source = offline.createBufferSource(); + source.buffer = audioBuffer; + source.connect(offline.destination); + source.start(0); + return offline.startRendering(); +} + +export async function convertBlobToWav(blob, { sampleRate = DEFAULT_SAMPLE_RATE, float32 = false, markers = null, trackStartOffsetSeconds = 0, audioContext = null } = {}) { + if (!blob) { + throw new Error('convertBlobToWav expects a Blob.'); + } + if (typeof window === 'undefined') { + throw new Error('convertBlobToWav requires a browser environment.'); + } + + const AudioContextCtor = window.AudioContext || window.webkitAudioContext; + if (!audioContext && !AudioContextCtor) { + throw new Error('AudioContext not supported in this environment.'); + } + + const context = audioContext || new AudioContextCtor(); + const shouldCloseContext = !audioContext && typeof context.close === 'function'; + + let audioBuffer; + try { + const arrayBuffer = await blob.arrayBuffer(); + const bufferCopy = arrayBuffer.slice(0); + audioBuffer = await new Promise((resolve, reject) => { + context.decodeAudioData(bufferCopy, resolve, reject); + }); + } catch (error) { + if (shouldCloseContext) { + await context.close(); + } + throw error; + } + + if (shouldCloseContext) { + await context.close(); + } + + let processedBuffer = audioBuffer; + try { + processedBuffer = await resampleIfNeeded(audioBuffer, sampleRate); + } catch (error) { + console.warn('Failed to resample audio buffer, using original sample rate', error); + } + + const wavArrayBuffer = audioBufferToWav(processedBuffer, { float32, markers, trackStartOffsetSeconds }); + return new Blob([wavArrayBuffer], { type: 'audio/wav' }); +} diff --git a/core/uploads/cloud-storage.js b/core/uploads/cloud-storage.js index 6b857f2..0d0f6d5 100644 --- a/core/uploads/cloud-storage.js +++ b/core/uploads/cloud-storage.js @@ -1,242 +1,242 @@ -import { waitForLegacySession } from '../legacy/session-bridge.js'; - -const DRIVE_CHUNK_ALIGNMENT = 256 * 1024; -const DEFAULT_DRIVE_CHUNK_SIZE = 4 * 1024 * 1024; -const DEFAULT_DROPBOX_CHUNK_SIZE = 8 * 1024 * 1024; - -function createAbortError() { - const error = new Error('Aborted'); - error.name = 'AbortError'; - return error; -} - -export class CloudUploadCoordinator { - constructor(session) { - this.session = session; - } - - static async create() { - const session = await waitForLegacySession(); - return new CloudUploadCoordinator(session); - } - - ensureDriveClient() { - if (!this.session.gdrive && typeof window.setupGoogleDriveUploader === 'function') { - this.session.gdrive = window.setupGoogleDriveUploader(); - } - return this.session.gdrive || null; - } - - async ensureDropboxClient(token, options = {}) { - let opts = options; - if (typeof token === 'object' && token !== null && !Array.isArray(token)) { - opts = token; - token = undefined; - } - opts = opts || {}; - const forceReauth = Boolean(opts.forceReauth); - if (typeof token === 'string' && token.trim().length) { - token = token.trim(); - } - const manualTokenProvided = typeof token === 'string' && token.length > 0; - const previousClient = this.session.dbx || null; - const oauthRecord = - (this.session && this.session.dropboxOAuth) || - (typeof session !== 'undefined' ? session.dropboxOAuth : null) || - null; - const oauthFresh = Boolean( - oauthRecord && (!oauthRecord.expiresAt || Date.now() < oauthRecord.expiresAt), - ); - if (!forceReauth && !manualTokenProvided && this.session.dbx && oauthFresh) { - return this.session.dbx; - } - if (typeof window.setupDropbox !== 'function') { - return this.session.dbx || null; - } - try { - const client = await window.setupDropbox(token, opts); - if (client) { - this.session.dbx = client; - return client; - } - } catch (error) { - if (forceReauth && previousClient && !this.session.dbx) { - this.session.dbx = previousClient; - } - throw error; - } - if (!this.session.dbx && previousClient) { - this.session.dbx = previousClient; - } - return this.session.dbx || null; -} - - startDriveUpload(filename, sessionUri) { - if (typeof window.setupGoogleDriveUploader !== 'function') { - throw new Error('Google Drive uploader is not available in this build.'); - } - return window.setupGoogleDriveUploader(filename, sessionUri); - } - - createDriveChunkWriter(filename, sessionUri) { - const uploader = this.startDriveUpload(filename, sessionUri); - return { - addChunk: (chunk) => uploader?.addChunk?.(chunk), - finalize: () => uploader?.finalize?.(), - uploader, - }; - } - - createDropboxChunkWriter(filename) { - if (typeof window.streamVideoToDropbox !== 'function') { - throw new Error('Dropbox uploader is not available in this build.'); - } - return window.streamVideoToDropbox(filename); - } - - hasDriveAccess() { - return Boolean(this.session.gdrive && this.session.gdrive.accessToken); - } - - hasDropboxAccess() { - return Boolean(this.session.dbx); - } - - async uploadBlob(blob, options = {}) { - if (!blob) { - throw new Error('uploadBlob expects a Blob.'); - } - const { - filename, - drive = true, - dropbox = true, - onProgress, - signal, - driveChunkSize = DEFAULT_DRIVE_CHUNK_SIZE, - dropboxChunkSize = DEFAULT_DROPBOX_CHUNK_SIZE, - } = options; - - const results = {}; - - if (drive) { - try { - results.drive = await this.uploadBlobToDrive(blob, { - filename, - onProgress, - signal, - chunkSize: driveChunkSize, - }); - } catch (error) { - results.drive = { status: 'error', service: 'drive', error }; - } - } else { - results.drive = { status: 'skipped', service: 'drive', reason: 'disabled' }; - } - - if (dropbox) { - try { - results.dropbox = await this.uploadBlobToDropbox(blob, { - filename, - onProgress, - signal, - chunkSize: dropboxChunkSize, - }); - } catch (error) { - results.dropbox = { status: 'error', service: 'dropbox', error }; - } - } else { - results.dropbox = { status: 'skipped', service: 'dropbox', reason: 'disabled' }; - } - - return results; - } - - async uploadBlobToDrive(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DRIVE_CHUNK_SIZE } = {}) { - const client = this.ensureDriveClient(); - if (!client) { - return { status: 'skipped', service: 'drive', reason: 'unavailable' }; - } - if (signal?.aborted) { - throw createAbortError(); - } - const name = filename || `recording-${Date.now()}.wav`; - let writer; - try { - writer = this.createDriveChunkWriter(name, this.session.gdrive?.sessionUri); - } catch (error) { - return { status: 'error', service: 'drive', error }; - } - if (!writer?.addChunk) { - return { status: 'error', service: 'drive', error: new Error('Drive writer unavailable') }; - } - const total = blob.size || 0; - const alignment = DRIVE_CHUNK_ALIGNMENT; - const adjustedChunkSize = Math.max(alignment, Math.floor(chunkSize / alignment) * alignment); - let uploaded = 0; - for (let offset = 0; offset < total; offset += adjustedChunkSize) { - if (signal?.aborted) { - throw createAbortError(); - } - const chunk = blob.slice(offset, Math.min(total, offset + adjustedChunkSize), blob.type || 'application/octet-stream'); - writer.addChunk(chunk); - uploaded += chunk.size; - if (typeof onProgress === 'function') { - onProgress({ - service: 'drive', - uploaded, - total, - percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0, - }); - } - } - writer.addChunk(false); - if (typeof writer.finalize === 'function') { - try { - await writer.finalize(); - } catch (error) { - return { status: 'error', service: 'drive', error }; - } - } - return { status: 'uploaded', service: 'drive', filename: name, bytes: uploaded }; - } - - async uploadBlobToDropbox(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DROPBOX_CHUNK_SIZE } = {}) { - const client = await this.ensureDropboxClient(); - if (!client) { - return { status: 'skipped', service: 'dropbox', reason: 'unavailable' }; - } - if (signal?.aborted) { - throw createAbortError(); - } - let writer; - const name = filename || `recording-${Date.now()}.wav`; - try { - writer = await this.createDropboxChunkWriter(name); - } catch (error) { - return { status: 'error', service: 'dropbox', error }; - } - if (typeof writer !== 'function') { - return { status: 'error', service: 'dropbox', error: new Error('Dropbox writer unavailable') }; - } - const total = blob.size || 0; - let uploaded = 0; - for (let offset = 0; offset < total; offset += chunkSize) { - if (signal?.aborted) { - throw createAbortError(); - } - const chunk = blob.slice(offset, Math.min(total, offset + chunkSize), blob.type || 'application/octet-stream'); - await writer(chunk); - uploaded += chunk.size; - if (typeof onProgress === 'function') { - onProgress({ - service: 'dropbox', - uploaded, - total, - percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0, - }); - } - } - await writer(false); - return { status: 'uploaded', service: 'dropbox', filename: name, bytes: uploaded }; - } -} +import { waitForLegacySession } from '../legacy/session-bridge.js'; + +const DRIVE_CHUNK_ALIGNMENT = 256 * 1024; +const DEFAULT_DRIVE_CHUNK_SIZE = 4 * 1024 * 1024; +const DEFAULT_DROPBOX_CHUNK_SIZE = 8 * 1024 * 1024; + +function createAbortError() { + const error = new Error('Aborted'); + error.name = 'AbortError'; + return error; +} + +export class CloudUploadCoordinator { + constructor(session) { + this.session = session; + } + + static async create() { + const session = await waitForLegacySession(); + return new CloudUploadCoordinator(session); + } + + ensureDriveClient() { + if (!this.session.gdrive && typeof window.setupGoogleDriveUploader === 'function') { + this.session.gdrive = window.setupGoogleDriveUploader(); + } + return this.session.gdrive || null; + } + + async ensureDropboxClient(token, options = {}) { + let opts = options; + if (typeof token === 'object' && token !== null && !Array.isArray(token)) { + opts = token; + token = undefined; + } + opts = opts || {}; + const forceReauth = Boolean(opts.forceReauth); + if (typeof token === 'string' && token.trim().length) { + token = token.trim(); + } + const manualTokenProvided = typeof token === 'string' && token.length > 0; + const previousClient = this.session.dbx || null; + const oauthRecord = + (this.session && this.session.dropboxOAuth) || + (typeof session !== 'undefined' ? session.dropboxOAuth : null) || + null; + const oauthFresh = Boolean( + oauthRecord && (!oauthRecord.expiresAt || Date.now() < oauthRecord.expiresAt), + ); + if (!forceReauth && !manualTokenProvided && this.session.dbx && oauthFresh) { + return this.session.dbx; + } + if (typeof window.setupDropbox !== 'function') { + return this.session.dbx || null; + } + try { + const client = await window.setupDropbox(token, opts); + if (client) { + this.session.dbx = client; + return client; + } + } catch (error) { + if (forceReauth && previousClient && !this.session.dbx) { + this.session.dbx = previousClient; + } + throw error; + } + if (!this.session.dbx && previousClient) { + this.session.dbx = previousClient; + } + return this.session.dbx || null; +} + + startDriveUpload(filename, sessionUri) { + if (typeof window.setupGoogleDriveUploader !== 'function') { + throw new Error('Google Drive uploader is not available in this build.'); + } + return window.setupGoogleDriveUploader(filename, sessionUri); + } + + createDriveChunkWriter(filename, sessionUri) { + const uploader = this.startDriveUpload(filename, sessionUri); + return { + addChunk: (chunk) => uploader?.addChunk?.(chunk), + finalize: () => uploader?.finalize?.(), + uploader, + }; + } + + createDropboxChunkWriter(filename) { + if (typeof window.streamVideoToDropbox !== 'function') { + throw new Error('Dropbox uploader is not available in this build.'); + } + return window.streamVideoToDropbox(filename); + } + + hasDriveAccess() { + return Boolean(this.session.gdrive && this.session.gdrive.accessToken); + } + + hasDropboxAccess() { + return Boolean(this.session.dbx); + } + + async uploadBlob(blob, options = {}) { + if (!blob) { + throw new Error('uploadBlob expects a Blob.'); + } + const { + filename, + drive = true, + dropbox = true, + onProgress, + signal, + driveChunkSize = DEFAULT_DRIVE_CHUNK_SIZE, + dropboxChunkSize = DEFAULT_DROPBOX_CHUNK_SIZE, + } = options; + + const results = {}; + + if (drive) { + try { + results.drive = await this.uploadBlobToDrive(blob, { + filename, + onProgress, + signal, + chunkSize: driveChunkSize, + }); + } catch (error) { + results.drive = { status: 'error', service: 'drive', error }; + } + } else { + results.drive = { status: 'skipped', service: 'drive', reason: 'disabled' }; + } + + if (dropbox) { + try { + results.dropbox = await this.uploadBlobToDropbox(blob, { + filename, + onProgress, + signal, + chunkSize: dropboxChunkSize, + }); + } catch (error) { + results.dropbox = { status: 'error', service: 'dropbox', error }; + } + } else { + results.dropbox = { status: 'skipped', service: 'dropbox', reason: 'disabled' }; + } + + return results; + } + + async uploadBlobToDrive(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DRIVE_CHUNK_SIZE } = {}) { + const client = this.ensureDriveClient(); + if (!client) { + return { status: 'skipped', service: 'drive', reason: 'unavailable' }; + } + if (signal?.aborted) { + throw createAbortError(); + } + const name = filename || `recording-${Date.now()}.wav`; + let writer; + try { + writer = this.createDriveChunkWriter(name, this.session.gdrive?.sessionUri); + } catch (error) { + return { status: 'error', service: 'drive', error }; + } + if (!writer?.addChunk) { + return { status: 'error', service: 'drive', error: new Error('Drive writer unavailable') }; + } + const total = blob.size || 0; + const alignment = DRIVE_CHUNK_ALIGNMENT; + const adjustedChunkSize = Math.max(alignment, Math.floor(chunkSize / alignment) * alignment); + let uploaded = 0; + for (let offset = 0; offset < total; offset += adjustedChunkSize) { + if (signal?.aborted) { + throw createAbortError(); + } + const chunk = blob.slice(offset, Math.min(total, offset + adjustedChunkSize), blob.type || 'application/octet-stream'); + writer.addChunk(chunk); + uploaded += chunk.size; + if (typeof onProgress === 'function') { + onProgress({ + service: 'drive', + uploaded, + total, + percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0, + }); + } + } + writer.addChunk(false); + if (typeof writer.finalize === 'function') { + try { + await writer.finalize(); + } catch (error) { + return { status: 'error', service: 'drive', error }; + } + } + return { status: 'uploaded', service: 'drive', filename: name, bytes: uploaded }; + } + + async uploadBlobToDropbox(blob, { filename, onProgress, signal, chunkSize = DEFAULT_DROPBOX_CHUNK_SIZE } = {}) { + const client = await this.ensureDropboxClient(); + if (!client) { + return { status: 'skipped', service: 'dropbox', reason: 'unavailable' }; + } + if (signal?.aborted) { + throw createAbortError(); + } + let writer; + const name = filename || `recording-${Date.now()}.wav`; + try { + writer = await this.createDropboxChunkWriter(name); + } catch (error) { + return { status: 'error', service: 'dropbox', error }; + } + if (typeof writer !== 'function') { + return { status: 'error', service: 'dropbox', error: new Error('Dropbox writer unavailable') }; + } + const total = blob.size || 0; + let uploaded = 0; + for (let offset = 0; offset < total; offset += chunkSize) { + if (signal?.aborted) { + throw createAbortError(); + } + const chunk = blob.slice(offset, Math.min(total, offset + chunkSize), blob.type || 'application/octet-stream'); + await writer(chunk); + uploaded += chunk.size; + if (typeof onProgress === 'function') { + onProgress({ + service: 'dropbox', + uploaded, + total, + percentage: total ? Math.min(100, Math.round((uploaded / total) * 100)) : 0, + }); + } + } + await writer(false); + return { status: 'uploaded', service: 'dropbox', filename: name, bytes: uploaded }; + } +} diff --git a/core/uploads/index.js b/core/uploads/index.js index 7c62b68..b9305ea 100644 --- a/core/uploads/index.js +++ b/core/uploads/index.js @@ -1 +1 @@ -export { CloudUploadCoordinator } from './cloud-storage.js'; +export { CloudUploadCoordinator } from './cloud-storage.js'; diff --git a/devices.css b/devices.css index 251253b..94a44b9 100644 --- a/devices.css +++ b/devices.css @@ -1,132 +1,132 @@ -body { - position:inherit; - height:unset; - padding-bottom:100px; -} - -#devices { - max-width: 80%; - width: fit-content; - margin: 0 auto; -} - -h1 { - font-size: 1.5em; - padding:10px; - background-color:#457b9d; - color:white; - border-bottom: 2px solid #3b6a87; -} - -.device { - display: flex; - flex-direction: column; - margin: 10px 0px; - font-size: 1rem; - padding: 10px; - position: relative; - background: #d0d0d0; - border-radius: 4px; -} - -.device.selected { - background-color: #3ea03c; -} - -.device.selected::before { - content: "\f00c"; - font-family: "Line Awesome Free"; - font-weight: 900; - position: absolute; - top: 10px; - right: 10px; -} - -.device:hover { - cursor: pointer; -} - -.device-name{ - font-weight: bold; - margin-bottom: 5px; -} - -.device-id { - -} - -.card { - margin: 10px; -} - -.card > div { - padding: 10px; -} - -.notice { - background-color: #fff18c; - margin: 10px; - padding: 20px 20px; - font-weight: bold; - font-size: 1.2em; - text-align: center; - line-height: 1.4em; -} - -.notice a { - color: #457b9d; -} - -@media only screen - and (min-device-width: 375px) - and (max-device-width: 812px) - and (orientation: portrait) { - #devices { - width: 100%; - max-width: 100%; - } - .device-id { - text-overflow: ellipsis; - overflow: hidden; - } -} - - -#sharedDevices { - position: fixed; - bottom: 20px; - width: 80%; - left: 10%; - color: white; - overflow-wrap: anywhere; - background: #2c3754; - padding: 20px; - box-shadow: 0px 0px 10px 5px #00000047; - border: 1px solid #333c52; -} - -#sharedDevices span { - display: block; - margin-bottom: 10px; -} - -#sharedDevices input { - width: 100%; - padding: 5px; - -} - -span#close { - position: absolute; - top: -10px; - right: -10px; - display: block; - width: 20px; - height: 20px; - background: #457b9d; - text-align: center; - border-radius: 20px; - line-height: 20px; - font-size: 20px; - cursor: pointer; +body { + position:inherit; + height:unset; + padding-bottom:100px; +} + +#devices { + max-width: 80%; + width: fit-content; + margin: 0 auto; +} + +h1 { + font-size: 1.5em; + padding:10px; + background-color:#457b9d; + color:white; + border-bottom: 2px solid #3b6a87; +} + +.device { + display: flex; + flex-direction: column; + margin: 10px 0px; + font-size: 1rem; + padding: 10px; + position: relative; + background: #d0d0d0; + border-radius: 4px; +} + +.device.selected { + background-color: #3ea03c; +} + +.device.selected::before { + content: "\f00c"; + font-family: "Line Awesome Free"; + font-weight: 900; + position: absolute; + top: 10px; + right: 10px; +} + +.device:hover { + cursor: pointer; +} + +.device-name{ + font-weight: bold; + margin-bottom: 5px; +} + +.device-id { + +} + +.card { + margin: 10px; +} + +.card > div { + padding: 10px; +} + +.notice { + background-color: #fff18c; + margin: 10px; + padding: 20px 20px; + font-weight: bold; + font-size: 1.2em; + text-align: center; + line-height: 1.4em; +} + +.notice a { + color: #457b9d; +} + +@media only screen + and (min-device-width: 375px) + and (max-device-width: 812px) + and (orientation: portrait) { + #devices { + width: 100%; + max-width: 100%; + } + .device-id { + text-overflow: ellipsis; + overflow: hidden; + } +} + + +#sharedDevices { + position: fixed; + bottom: 20px; + width: 80%; + left: 10%; + color: white; + overflow-wrap: anywhere; + background: #2c3754; + padding: 20px; + box-shadow: 0px 0px 10px 5px #00000047; + border: 1px solid #333c52; +} + +#sharedDevices span { + display: block; + margin-bottom: 10px; +} + +#sharedDevices input { + width: 100%; + padding: 5px; + +} + +span#close { + position: absolute; + top: -10px; + right: -10px; + display: block; + width: 20px; + height: 20px; + background: #457b9d; + text-align: center; + border-radius: 20px; + line-height: 20px; + font-size: 20px; + cursor: pointer; } \ No newline at end of file diff --git a/iframe.css b/iframe.css index 899405a..cdbac91 100644 --- a/iframe.css +++ b/iframe.css @@ -1,200 +1,200 @@ -body { - padding: 10px; - margin: 0; - background-color: #e1e8fc; - font-family: Arial, Helvetica, sans-serif; -} - -iframe { - border: 0; - margin: 0; - padding: 0; - display: block; - width: 100%; - height: 100%; -} - -p { - margin-top: 0; -} - -h3 { - margin-top: 0; - font-size: 20px; - font-weight: 300; -} - -h4 { - margin-top: 5px; - margin-bottom: 5px; -} - -input { - padding: 5px; - margin-bottom: 5px; - margin-right: 5px; -} - -button { - padding: 5px; - background: white; - border: solid; - border-radius: 5px; - cursor: pointer; - margin-bottom: 5px; -} - -button:not(:last-child) { - margin-right: 5px; -} - -video { - max-width: 300px; - max-height: 100px; -} - -#viewlink { - width: 400px; -} - -#container { - display: flex; - padding: 0; - flex-direction: column; -} - -.api-section { - padding: 10px; - border-bottom: solid 1px; -} - -.api-section > h4:first-child { - margin-top: 0; -} - -.api-section-header { - display: flex; - width: 100%; - padding: 10px; - border-bottom: solid 1px; - justify-content: space-between; - box-sizing: border-box; - align-items: center; -} - -.custom-post { - display: flex; -} - -.custom-post > * { - margin: 0; -} - -.custom-post > button { - border-radius: 0; - border: 0; - padding: 0 20px; - font-size: 16px; - font-family: monospace; -} - -.custom-post-input { - width: 100%; - padding: 10px; - border: solid 5px #4d66a8; - font-family: monospace; -} - -.controls { - overflow: auto; - background: white; - width: 350px; - flex-shrink: 0; - box-sizing: border-box; - height: 100%; -} - -.example-body { - display: flex; - width: 100%; - height: 700px; -} - -.example-header { - display: flex; - width: 100%; - background: #4d66a8; - color: white; - padding: 10px; - box-sizing: border-box; - justify-content: space-between; - align-items: center; - font-size: 22px; -} - -.example-header > button { - margin: 0; -} - -.hidden { - display: none; -} - -.iframe-example { - display: flex; - flex-direction: column; - margin-bottom: 20px; - border: solid #4d66a8 2px; -} - -.main-log { - width: 100%; - padding: 10px; - flex-shrink: 0; - height: 200px; - display: flex; - flex-direction: column; - overflow: auto; - background: #0b0e15; - color: white; - border-top: solid 1px #6e6e6e; - font-family: monospace; - box-sizing: border-box; -} - -.output-container { - position: relative; - height: 100%; - display: flex; - flex-direction: column; - width: 100%; -} - -.post-log { - background: #273047; - padding: 4px; - margin-top: 10px; -} - -.sensors-log { - padding: 10px; - width: 200px; -} - -.stream-data-logs { - height: 100%; - background: black; - color: white; - font-family: monospace; - font-size: 12px; - flex-shrink: 0; -} - -.target-guest { - padding: 10px; - border-bottom: solid 1px; -} - -.target-guest-inputs { - margin-top: 5px; -} +body { + padding: 10px; + margin: 0; + background-color: #e1e8fc; + font-family: Arial, Helvetica, sans-serif; +} + +iframe { + border: 0; + margin: 0; + padding: 0; + display: block; + width: 100%; + height: 100%; +} + +p { + margin-top: 0; +} + +h3 { + margin-top: 0; + font-size: 20px; + font-weight: 300; +} + +h4 { + margin-top: 5px; + margin-bottom: 5px; +} + +input { + padding: 5px; + margin-bottom: 5px; + margin-right: 5px; +} + +button { + padding: 5px; + background: white; + border: solid; + border-radius: 5px; + cursor: pointer; + margin-bottom: 5px; +} + +button:not(:last-child) { + margin-right: 5px; +} + +video { + max-width: 300px; + max-height: 100px; +} + +#viewlink { + width: 400px; +} + +#container { + display: flex; + padding: 0; + flex-direction: column; +} + +.api-section { + padding: 10px; + border-bottom: solid 1px; +} + +.api-section > h4:first-child { + margin-top: 0; +} + +.api-section-header { + display: flex; + width: 100%; + padding: 10px; + border-bottom: solid 1px; + justify-content: space-between; + box-sizing: border-box; + align-items: center; +} + +.custom-post { + display: flex; +} + +.custom-post > * { + margin: 0; +} + +.custom-post > button { + border-radius: 0; + border: 0; + padding: 0 20px; + font-size: 16px; + font-family: monospace; +} + +.custom-post-input { + width: 100%; + padding: 10px; + border: solid 5px #4d66a8; + font-family: monospace; +} + +.controls { + overflow: auto; + background: white; + width: 350px; + flex-shrink: 0; + box-sizing: border-box; + height: 100%; +} + +.example-body { + display: flex; + width: 100%; + height: 700px; +} + +.example-header { + display: flex; + width: 100%; + background: #4d66a8; + color: white; + padding: 10px; + box-sizing: border-box; + justify-content: space-between; + align-items: center; + font-size: 22px; +} + +.example-header > button { + margin: 0; +} + +.hidden { + display: none; +} + +.iframe-example { + display: flex; + flex-direction: column; + margin-bottom: 20px; + border: solid #4d66a8 2px; +} + +.main-log { + width: 100%; + padding: 10px; + flex-shrink: 0; + height: 200px; + display: flex; + flex-direction: column; + overflow: auto; + background: #0b0e15; + color: white; + border-top: solid 1px #6e6e6e; + font-family: monospace; + box-sizing: border-box; +} + +.output-container { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + width: 100%; +} + +.post-log { + background: #273047; + padding: 4px; + margin-top: 10px; +} + +.sensors-log { + padding: 10px; + width: 200px; +} + +.stream-data-logs { + height: 100%; + background: black; + color: white; + font-family: monospace; + font-size: 12px; + flex-shrink: 0; +} + +.target-guest { + padding: 10px; + border-bottom: solid 1px; +} + +.target-guest-inputs { + margin-top: 5px; +} diff --git a/iframe.html b/iframe.html index 96f0381..f51f53c 100644 --- a/iframe.html +++ b/iframe.html @@ -1,1299 +1,1299 @@ - - - - VDO.Ninja IFRAME API - Interactive Developer Console - - - - - - - -
-

🚀 VDO.Ninja IFRAME API - Interactive Developer Console

-

Test and explore the VDO.Ninja IFRAME API with this interactive tool. Enter a VDO.Ninja URL below to get started.

-
- -
- - - -
- - - -
-
- -
-
- 📖 Quick Start Guide -
-

Getting Started:

-
    -
  1. Enter a VDO.Ninja URL in the input field above
  2. -
  3. Click "ADD IFRAME" to create a new instance
  4. -
  5. Use the control panel to send commands
  6. -
  7. Monitor responses in the log window
  8. -
- -

Tips:

-
    -
  • Use Ctrl+K to focus the JSON input
  • -
  • Use Ctrl+Enter in JSON input to send
  • -
  • Click on log entries to copy their content
  • -
  • Check the browser console for detailed debugging
  • -
- -

Common Use Cases:

-
    -
  • Testing: Verify API commands work correctly
  • -
  • Debugging: Monitor events and responses
  • -
  • Development: Prototype integrations
  • -
  • Learning: Explore API capabilities
  • -
-
-
- -
- 🔗 Useful Links - -
-
- -
- - - + + + + VDO.Ninja IFRAME API - Interactive Developer Console + + + + + + + +
+

🚀 VDO.Ninja IFRAME API - Interactive Developer Console

+

Test and explore the VDO.Ninja IFRAME API with this interactive tool. Enter a VDO.Ninja URL below to get started.

+
+ +
+ + + +
+ + + +
+
+ +
+
+ 📖 Quick Start Guide +
+

Getting Started:

+
    +
  1. Enter a VDO.Ninja URL in the input field above
  2. +
  3. Click "ADD IFRAME" to create a new instance
  4. +
  5. Use the control panel to send commands
  6. +
  7. Monitor responses in the log window
  8. +
+ +

Tips:

+
    +
  • Use Ctrl+K to focus the JSON input
  • +
  • Use Ctrl+Enter in JSON input to send
  • +
  • Click on log entries to copy their content
  • +
  • Check the browser console for detailed debugging
  • +
+ +

Common Use Cases:

+
    +
  • Testing: Verify API commands work correctly
  • +
  • Debugging: Monitor events and responses
  • +
  • Development: Prototype integrations
  • +
  • Learning: Explore API capabilities
  • +
+
+
+ +
+ 🔗 Useful Links + +
+
+ +
+ + + \ No newline at end of file diff --git a/images/hd.svg b/images/hd.svg index c107866..6afd4b5 100644 --- a/images/hd.svg +++ b/images/hd.svg @@ -1,2 +1,2 @@ - background Layer 1 - HQ + background Layer 1 + HQ diff --git a/images/sd.svg b/images/sd.svg index 7c3fa3e..7d6fe9c 100644 --- a/images/sd.svg +++ b/images/sd.svg @@ -1,2 +1,2 @@ - background Layer 1 - LQ + background Layer 1 + LQ diff --git a/index.html b/index.html index d1610bd..297d263 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,11 @@ } + - - -
-

iFrame Mixer

- -
-
-
- - -
-
-
-
- - -
-
-
- - - - - - - - - - -
- -
- -
-
- -
- -
- - - + + + + + + iFrame Mixer + + + +
+

iFrame Mixer

+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ + + + + + + + + + +
+ +
+ +
+
+ +
+ +
+ + + \ No newline at end of file diff --git a/podcast/audio-metering.md b/podcast/audio-metering.md index 6830d2e..d670182 100644 --- a/podcast/audio-metering.md +++ b/podcast/audio-metering.md @@ -1,49 +1,49 @@ -# Audio Meter Pipeline Spec - -## Legacy Behavior -- Inbound audio tracks enter `session.rpcs[UUID].inboundAudioPipeline` where `audioMeterGuest()` injects an `AnalyserNode` (`lib.js:46573-47782`). -- Loudness is computed by averaging FFT bins every 100 ms and mapped to DOM meters (`voiceMeter` elements) with style variants selected via `session.meterStyle` (`lib.js:47703-47771`). -- Meter values also feed optional features like `session.pushLoudness`, active-speaker highlighting, and iframe postMessage events. -- Meter UI updates live inside the analyser loop, coupling audio processing with DOM manipulation. - -## Podcast Studio Requirements -1. **Per-track telemetry**: expose peak, RMS, and clip count per participant stream. -2. **Low-latency visuals**: 30–60 FPS updates for waveform bars in the studio shell, separate from DOM-heavy legacy meters. -3. **Extensible alerts**: thresholds for clipping, silence detection, and noisy floors. -4. **Headless access**: other modules (recording, AI markers) should subscribe without touching DOM. - -## Proposed Architecture -``` -MediaStreamTrack - └─▶ MeterNode (AudioWorklet + SharedArrayBuffer) - ├─▶ LevelBus.publish({ uuid, peak, rms, crest, clipped }) - └─▶ Optional legacy bridge updates DOM meters via event listener -Studio UI (podcast-shell) - └─▶ subscribes to LevelBus for visual meters, notifications, analytics -Recording Service - └─▶ listens for clip/silence to insert markers, trigger backups -``` - -### MeterNode Design -- Build an `AudioWorkletProcessor` (`core/meter.worklet.js`) that computes: - - Instantaneous peak (max absolute sample). - - RMS over 1024-sample windows. - - Clipped sample counter (threshold > -1 dBFS). -- Post results through a `MessagePort` to the main thread every 32 ms, keeping GC pressure low. -- Provide `connect(stream, { uuid })` helper in `core/audio/meters.js`. - -### Event Bus -- Introduce `LevelBus` as a lightweight `EventTarget` in `core/events/level-bus.js`. -- Emit `level` events with payload `{ uuid, trackId, peak, rms, clipped, timestamp }`. -- Legacy UI can attach a listener that maps to existing `voiceMeter` DOM updates until replaced. - -### Thresholds & Alerts -- Default clip alert: `peak >= -1 dBFS` for three consecutive frames. -- Silence alert: `rms <= -60 dBFS` for >3 seconds. -- Provide `LevelPolicy` configuration for per-room overrides. - -## Implementation Steps -1. Implement `MeterWorkletProcessor` and register via `audioContext.audioWorklet.addModule()`. -2. Wrap legacy `audioMeterGuest` to forward analyser metrics into `LevelBus` before modifying DOM. -3. Update podcast studio shell to subscribe for UI meters, enabling waveform/LED displays. -4. Expose metrics to recording service for embedding markers in local/remote tracks. +# Audio Meter Pipeline Spec + +## Legacy Behavior +- Inbound audio tracks enter `session.rpcs[UUID].inboundAudioPipeline` where `audioMeterGuest()` injects an `AnalyserNode` (`lib.js:46573-47782`). +- Loudness is computed by averaging FFT bins every 100 ms and mapped to DOM meters (`voiceMeter` elements) with style variants selected via `session.meterStyle` (`lib.js:47703-47771`). +- Meter values also feed optional features like `session.pushLoudness`, active-speaker highlighting, and iframe postMessage events. +- Meter UI updates live inside the analyser loop, coupling audio processing with DOM manipulation. + +## Podcast Studio Requirements +1. **Per-track telemetry**: expose peak, RMS, and clip count per participant stream. +2. **Low-latency visuals**: 30–60 FPS updates for waveform bars in the studio shell, separate from DOM-heavy legacy meters. +3. **Extensible alerts**: thresholds for clipping, silence detection, and noisy floors. +4. **Headless access**: other modules (recording, AI markers) should subscribe without touching DOM. + +## Proposed Architecture +``` +MediaStreamTrack + └─▶ MeterNode (AudioWorklet + SharedArrayBuffer) + ├─▶ LevelBus.publish({ uuid, peak, rms, crest, clipped }) + └─▶ Optional legacy bridge updates DOM meters via event listener +Studio UI (podcast-shell) + └─▶ subscribes to LevelBus for visual meters, notifications, analytics +Recording Service + └─▶ listens for clip/silence to insert markers, trigger backups +``` + +### MeterNode Design +- Build an `AudioWorkletProcessor` (`core/meter.worklet.js`) that computes: + - Instantaneous peak (max absolute sample). + - RMS over 1024-sample windows. + - Clipped sample counter (threshold > -1 dBFS). +- Post results through a `MessagePort` to the main thread every 32 ms, keeping GC pressure low. +- Provide `connect(stream, { uuid })` helper in `core/audio/meters.js`. + +### Event Bus +- Introduce `LevelBus` as a lightweight `EventTarget` in `core/events/level-bus.js`. +- Emit `level` events with payload `{ uuid, trackId, peak, rms, clipped, timestamp }`. +- Legacy UI can attach a listener that maps to existing `voiceMeter` DOM updates until replaced. + +### Thresholds & Alerts +- Default clip alert: `peak >= -1 dBFS` for three consecutive frames. +- Silence alert: `rms <= -60 dBFS` for >3 seconds. +- Provide `LevelPolicy` configuration for per-room overrides. + +## Implementation Steps +1. Implement `MeterWorkletProcessor` and register via `audioContext.audioWorklet.addModule()`. +2. Wrap legacy `audioMeterGuest` to forward analyser metrics into `LevelBus` before modifying DOM. +3. Update podcast studio shell to subscribe for UI meters, enabling waveform/LED displays. +4. Expose metrics to recording service for embedding markers in local/remote tracks. diff --git a/podcast/bootstrap.js b/podcast/bootstrap.js index f58436c..d7f84d2 100644 --- a/podcast/bootstrap.js +++ b/podcast/bootstrap.js @@ -1,7 +1,7 @@ -const params = new URLSearchParams(window.location.search); -const studioMode = params.has('podcast'); - -if (studioMode) { - import('./studio.js'); - document.body.style.display = "unset"; -} +const params = new URLSearchParams(window.location.search); +const studioMode = params.has('podcast'); + +if (studioMode) { + import('./studio.js'); + document.body.style.display = "unset"; +} diff --git a/podcast/index.html b/podcast/index.html new file mode 100644 index 0000000..ad70a47 --- /dev/null +++ b/podcast/index.html @@ -0,0 +1,13 @@ + + + + + Redirecting to Podcast Studio... + + + +

Redirecting to Podcast Studio...

+ + diff --git a/podcast/recording-flow.md b/podcast/recording-flow.md index c185171..3aae32c 100644 --- a/podcast/recording-flow.md +++ b/podcast/recording-flow.md @@ -1,39 +1,39 @@ -# Recording Flow Map - -## High-Level Sequence -1. UI controls in `main.js` set up recording defaults from URL params and user actions (`recordLocalbutton`, `recordRemote` buttons). Key lines: `main.js:1841-1905`, `main.js:7364-7379`. -2. These controls call `recordLocalVideoToggle()` and `recordLocalVideo()` in `lib.js` to arm, start, and stop recordings (`lib.js:44903-45480`). -3. `recordLocalVideo()` configures a per-video recorder object that wraps `MediaRecorder`, local `WritableStream` writers, and optional Drive/Dropbox chunk handlers (`lib.js:45213-45480`). -4. When chunked/WebCodec capture is enabled, `recordLocalVideo()` delegates to the `session.webCodec` / `session.webCodecAudio` pipelines inside `webrtc.js` which stream encoded frames through `session.chunkedRecorder` (`webrtc.js:10877-13047`). -5. Recorder stop events fan out to cleanup functions, UI updates, and remote directors via `session.sendMessage` (`lib.js:45361-45466`). - -## Detailed Call Graph -``` -main.js UI Events - └─▶ recordLocalVideoToggle(action?) (lib.js) - ├─▶ recordLocalVideo('start' | 'stop', configureRecording?, remote?, altUUID?) - │ ├─▶ setupRecorder(videoElement) - │ │ ├─▶ new MediaRecorder(track mix) - │ │ ├─▶ configure writer → video.recorder.writer (stream saver) - │ │ ├─▶ bind `ondataavailable` → append blobs + dispatch cloud uploads - │ │ └─▶ handle PCM/WebCodec fallbacks - │ ├─▶ session.webCodec / session.webCodecAudio (webrtc.js) when `configureRecording` && chunking enabled - │ │ └─▶ session.chunkedRecorder.enqueueFrame / sendChunks (WebSocket/WebTransport relays) - │ └─▶ init Dropbox / Drive handlers (lib.js:45134-45212) - └─▶ updateLocalRecordButton / updateRemoteRecordButton -``` - -## Data & Storage Paths -- **Local Mixdown**: Default `MediaRecorder` mux of mixed `MediaStream`. Saves locally via `video.recorder.writer` or triggers download prompts (`lib.js:45034-45300`). -- **Per-Chunk Cloud Upload**: When Drive/Dropbox connected, `addChunk` and `write` callbacks push each blob to remote APIs for redundancy (`lib.js:45263-45299`). -- **Chunked WebCodec**: High-fidelity pipeline builds encoded frames manually, tracks reliability metadata, and streams to relays (`webrtc.js:11192-12698`). - -## Observed Pain Points -- Recorder is single-mix: we mix all remote audio/video into one stream before `MediaRecorder`, blocking isolated track capture. -- Recorder lifecycle interweaves UI state (`recordLocalbutton`) making headless reuse difficult. -- WebCodec chunker shares buffers for audio & video; multi-track audio will need new stream management and file packaging. - -## Refactor Targets -- Extract recorder setup into `core/recording` service with explicit track selection. -- Decouple UI updates from recorder state via events, allowing alternate shells (podcast studio) to subscribe. -- Introduce multi-track pipeline that uses `session.peers[UUID].stream` assets and writes separate WAV files per participant before mixdown. +# Recording Flow Map + +## High-Level Sequence +1. UI controls in `main.js` set up recording defaults from URL params and user actions (`recordLocalbutton`, `recordRemote` buttons). Key lines: `main.js:1841-1905`, `main.js:7364-7379`. +2. These controls call `recordLocalVideoToggle()` and `recordLocalVideo()` in `lib.js` to arm, start, and stop recordings (`lib.js:44903-45480`). +3. `recordLocalVideo()` configures a per-video recorder object that wraps `MediaRecorder`, local `WritableStream` writers, and optional Drive/Dropbox chunk handlers (`lib.js:45213-45480`). +4. When chunked/WebCodec capture is enabled, `recordLocalVideo()` delegates to the `session.webCodec` / `session.webCodecAudio` pipelines inside `webrtc.js` which stream encoded frames through `session.chunkedRecorder` (`webrtc.js:10877-13047`). +5. Recorder stop events fan out to cleanup functions, UI updates, and remote directors via `session.sendMessage` (`lib.js:45361-45466`). + +## Detailed Call Graph +``` +main.js UI Events + └─▶ recordLocalVideoToggle(action?) (lib.js) + ├─▶ recordLocalVideo('start' | 'stop', configureRecording?, remote?, altUUID?) + │ ├─▶ setupRecorder(videoElement) + │ │ ├─▶ new MediaRecorder(track mix) + │ │ ├─▶ configure writer → video.recorder.writer (stream saver) + │ │ ├─▶ bind `ondataavailable` → append blobs + dispatch cloud uploads + │ │ └─▶ handle PCM/WebCodec fallbacks + │ ├─▶ session.webCodec / session.webCodecAudio (webrtc.js) when `configureRecording` && chunking enabled + │ │ └─▶ session.chunkedRecorder.enqueueFrame / sendChunks (WebSocket/WebTransport relays) + │ └─▶ init Dropbox / Drive handlers (lib.js:45134-45212) + └─▶ updateLocalRecordButton / updateRemoteRecordButton +``` + +## Data & Storage Paths +- **Local Mixdown**: Default `MediaRecorder` mux of mixed `MediaStream`. Saves locally via `video.recorder.writer` or triggers download prompts (`lib.js:45034-45300`). +- **Per-Chunk Cloud Upload**: When Drive/Dropbox connected, `addChunk` and `write` callbacks push each blob to remote APIs for redundancy (`lib.js:45263-45299`). +- **Chunked WebCodec**: High-fidelity pipeline builds encoded frames manually, tracks reliability metadata, and streams to relays (`webrtc.js:11192-12698`). + +## Observed Pain Points +- Recorder is single-mix: we mix all remote audio/video into one stream before `MediaRecorder`, blocking isolated track capture. +- Recorder lifecycle interweaves UI state (`recordLocalbutton`) making headless reuse difficult. +- WebCodec chunker shares buffers for audio & video; multi-track audio will need new stream management and file packaging. + +## Refactor Targets +- Extract recorder setup into `core/recording` service with explicit track selection. +- Decouple UI updates from recorder state via events, allowing alternate shells (podcast studio) to subscribe. +- Introduce multi-track pipeline that uses `session.peers[UUID].stream` assets and writes separate WAV files per participant before mixdown. diff --git a/podcast/studio.css b/podcast/studio.css index ade1c00..014d59e 100644 --- a/podcast/studio.css +++ b/podcast/studio.css @@ -1,1734 +1,2164 @@ -body.podcast-studio-mode { - background: radial-gradient(circle at 10% 20%, #191f2c 0%, #0b0d13 70%); - color: #f5f7ff; - font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; - min-height: 100vh; - padding: 0; - margin: 0; -} - -body.podcast-studio-mode > :not(#podcast-root):not(#podcast-room-gate):not([data-podcast-overlay="true"]):not(script):not(link) { - display: none !important; -} - -#podcast-root { - display: flex; - flex-direction: column; - min-height: 100vh; - backdrop-filter: blur(14px); -} - -.podcast-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 32px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.podcast-header h1 { - font-size: 1.4rem; - margin: 0; - letter-spacing: 0.04em; -} - -.podcast-status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 0.85rem; - padding: 6px 12px; - border-radius: 999px; - background: rgba(10, 194, 118, 0.12); - color: #2fe7a3; - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.podcast-main { - flex: 1; - display: grid; - grid-template-columns: 320px 1fr; - gap: 24px; - padding: 24px 32px 32px; - overflow: hidden; -} - -.podcast-roster { - display: flex; - flex-direction: column; - gap: 16px; -} - -.podcast-panel { - background: rgba(15, 20, 32, 0.78); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 18px; - padding: 20px; - box-shadow: 0 24px 48px -32px rgba(9, 11, 19, 0.8); -} - -.podcast-panel h2 { - margin: 0 0 12px; - font-size: 1.05rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.7); -} - -.roster-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.roster-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-radius: 14px; - background: rgba(23, 30, 46, 0.9); - border: 1px solid transparent; - transition: border-color 120ms ease; -} - -.roster-item[data-status="connected"] { - border-color: rgba(47, 231, 163, 0.3); -} - -.roster-item[data-status="connecting"] { - border-color: rgba(255, 196, 72, 0.3); -} - -.roster-meta { - display: flex; - flex-direction: column; - gap: 4px; -} - -.roster-name { - font-weight: 600; - letter-spacing: 0.04em; -} - -.roster-id { - font-size: 0.75rem; - opacity: 0.6; -} - -.roster-role { - font-size: 0.7rem; - opacity: 0.55; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.meter-bar { - position: relative; - width: 140px; - height: 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - overflow: hidden; -} - -.meter-bar-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 1%; - transform-origin: left center; - background: linear-gradient(90deg, #29f19c 0%, #f2d95c 70%, #ff5c69 100%); - transition: width 100ms linear; -} - -.roster-actions { - display: flex; - flex-direction: column; - gap: 6px; - align-items: flex-end; - min-width: 180px; -} - -.roster-action-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.roster-action-button { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.85); - border-radius: 999px; - padding: 6px 14px; - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.roster-action-button:hover { - background: rgba(47, 231, 163, 0.16); - border-color: rgba(47, 231, 163, 0.4); - color: #2fe7a3; -} - -.roster-action-button[disabled] { - opacity: 0.45; - cursor: not-allowed; -} - -.roster-action-button--drive[data-state="active"] { - background: rgba(47, 231, 163, 0.22); - border-color: rgba(47, 231, 163, 0.45); - color: #2fe7a3; -} - -.roster-drive-status { - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - opacity: 0.58; - color: rgba(255, 255, 255, 0.75); -} - -.roster-drive-status[data-state="uploading"], -.roster-drive-status[data-state="pending"] { - color: #2fe7a3; - opacity: 0.85; -} - -.roster-drive-status[data-state="done"] { - color: #8fd8ff; - opacity: 0.9; -} - -.roster-drive-status[data-state="error"] { - color: #ff7d88; - opacity: 0.95; -} - -.podcast-console { - display: flex; - flex-direction: column; - gap: 20px; -} - -.podcast-console-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - align-items: start; -} - -.console-grid__span-2 { - grid-column: 1 / -1; -} - -.session-tools__grid { - display: flex; - flex-wrap: wrap; - gap: 22px; - align-items: flex-start; -} - -.session-tool { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 18px; - border-radius: 14px; - background: rgba(22, 28, 44, 0.65); - border: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: inset 0 0 28px -24px rgba(0, 0, 0, 0.9); - flex: 0 0 auto; - min-width: 220px; -} - -.session-tool__title { - margin: 0; - font-size: 0.95rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.68); -} - - -.session-info { - opacity: 0.6; - font-size: 0.85rem; - letter-spacing: 0.05em; - flex: 0 0 auto; - text-align: right; -} - -.session-tool--control .session-info { - margin-left: 18px; -} - -.session-tool--cloud { - gap: 10px; -} - -.session-tool--control .transport-strip { - display: inline-flex; - align-items: center; - gap: 16px; - margin-top: 4px; -} - -.session-tool--control .transport-buttons { - display: inline-flex; - gap: 12px; -} - -@media (max-width: 900px) { - .podcast-console-grid { - grid-template-columns: 1fr; - } - .session-tools__grid { - flex-direction: column; - } -} - -.invite-panel p { - margin: 0 0 12px; - font-size: 0.85rem; - opacity: 0.7; -} - -.invite-link-row { - display: grid; - grid-template-columns: 1fr auto; - gap: 10px; - align-items: center; - margin-bottom: 8px; -} - -.invite-link-input { - background: rgba(8, 12, 22, 0.88); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 12px; - color: rgba(255, 255, 255, 0.92); - padding: 10px 14px; - font-size: 0.9rem; - letter-spacing: 0.02em; -} - -.invite-link-input[data-state='placeholder'] { - color: rgba(255, 255, 255, 0.45); - font-style: italic; -} - -.invite-link-copy { - background: rgba(47, 231, 163, 0.18); - border: 1px solid rgba(47, 231, 163, 0.4); - border-radius: 12px; - color: #2fe7a3; - padding: 10px 16px; - font-size: 0.85rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, color 120ms ease; -} - -.invite-link-copy:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.invite-link-copy:not(:disabled):hover { - background: rgba(47, 231, 163, 0.28); -} - -.invite-status { - font-size: 0.75rem; - letter-spacing: 0.04em; - text-transform: uppercase; - opacity: 0.65; - margin-bottom: 12px; -} - -.invite-status[data-variant='success'] { - color: #2fe7a3; - opacity: 0.85; -} - -.invite-status[data-variant='warning'] { - color: #f2d95c; - opacity: 0.85; -} - -.invite-options { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 10px; -} - -.invite-option { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.8rem; - opacity: 0.85; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - padding: 8px 10px; - border-radius: 12px; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease; -} - -.invite-option:hover { - background: rgba(47, 231, 163, 0.12); - border-color: rgba(47, 231, 163, 0.28); - opacity: 1; -} - -.invite-option__checkbox { - width: 16px; - height: 16px; - accent-color: #2fe7a3; -} - -.invite-option__label { - flex: 1; -} - -.chat-panel { - display: flex; - flex-direction: column; - gap: 12px; -} - -.chat-panel__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.chat-panel__header h2 { - margin: 0; -} - -.chat-panel__actions { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.chat-panel__action { - background: rgba(95, 142, 255, 0.18); - color: #a6c8ff; - border: 1px solid rgba(95, 142, 255, 0.32); - border-radius: 999px; - padding: 6px 12px; - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: background 120ms ease, opacity 120ms ease, transform 120ms ease; -} - -.chat-panel__action:hover { - transform: translateY(-1px); - background: rgba(95, 142, 255, 0.26); - color: #d7e6ff; -} - -.chat-panel__toggle { - background: rgba(47, 231, 163, 0.18); - border-color: rgba(47, 231, 163, 0.36); - color: #2fe7a3; -} - -.chat-panel[data-collapsed='true'] .chat-panel__toggle { - background: rgba(95, 142, 255, 0.22); - border-color: rgba(95, 142, 255, 0.34); - color: #a6c8ff; -} - -.chat-panel__body { - display: flex; - flex-direction: column; - gap: 12px; -} - -.chat-panel__collapsed-hint { - display: none; - font-size: 0.78rem; - opacity: 0.65; - letter-spacing: 0.05em; -} - -.chat-panel[data-collapsed='true'] .chat-panel__body { - display: none; -} - -.chat-panel[data-collapsed='true'] .chat-panel__collapsed-hint { - display: block; -} - -.chat-panel__empty { - font-size: 0.85rem; - opacity: 0.65; -} - -.transport-strip { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.transport-buttons { - display: inline-flex; - gap: 12px; - flex-wrap: wrap; -} - -.transport-buttons button { - background: rgba(47, 231, 163, 0.18); - color: #2fe7a3; - border: 1px solid rgba(47, 231, 163, 0.4); - border-radius: 999px; - padding: 10px 18px; - font-size: 0.95rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: transform 120ms ease, background 120ms ease; -} - -.transport-buttons button.recording { - background: rgba(255, 92, 105, 0.18); - border-color: rgba(255, 92, 105, 0.4); - color: #fff8fb; - text-shadow: 0 0 6px rgba(138, 21, 30, 0.45); -} - -.transport-buttons button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.transport-disk { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 14px; - background: rgba(13, 16, 26, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - min-width: 220px; -} - -.transport-disk__header { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.72); -} - -.transport-disk__toggle { - background: rgba(47, 231, 163, 0.12); - border: 1px solid rgba(47, 231, 163, 0.35); - border-radius: 999px; - color: #2fe7a3; - padding: 8px 16px; - font-size: 0.85rem; - letter-spacing: 0.06em; - text-transform: uppercase; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.transport-disk__toggle[data-state='enabled'] { - background: rgba(47, 231, 163, 0.25); - color: #073b26; -} - -.transport-disk__toggle:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.transport-disk__status { - font-size: 0.72rem; - letter-spacing: 0.04em; - color: rgba(255, 255, 255, 0.7); -} - -.transport-disk__status[data-state='error'] { - color: #f86e7b; -} - -.transport-disk__status[data-state='ready'] { - color: #2fe7a3; -} - -.transport-disk--panel { - width: 100%; -} - -.transport-disk__hint { - margin-top: 8px; - font-size: 0.78rem; - opacity: 0.68; -} - -.session-recording-status { - font-size: 0.82rem; - padding: 6px 12px; - border-left: 2px solid rgba(255, 255, 255, 0.18); - border-radius: 10px; - background: rgba(12, 15, 26, 0.9); - color: rgba(255, 255, 255, 0.78); - min-width: 200px; -} - -.session-recording-status[data-state='arming'], -.session-recording-status[data-state='armed'] { - border-color: rgba(255, 204, 102, 0.65); - color: #ffcc66; -} - -.session-recording-status[data-state='active'] { - border-color: rgba(47, 231, 163, 0.65); - color: #2fe7a3; -} - -.session-recording-status[data-state='stopping'] { - border-color: rgba(255, 92, 105, 0.65); - color: #ff5c69; -} - -.session-recording-status[data-state='error'] { - border-color: rgba(255, 92, 105, 0.9); - color: #ff5c69; -} - -.transport-disk__folder { - align-self: flex-start; - background: rgba(95, 142, 255, 0.18); - border: 1px solid rgba(95, 142, 255, 0.35); - border-radius: 10px; - color: #a6c8ff; - padding: 6px 12px; - font-size: 0.78rem; - letter-spacing: 0.05em; - cursor: pointer; -} - -.transport-disk__folder:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.marker-log { - display: flex; - flex-direction: column; - gap: 8px; - max-height: 160px; - overflow-y: auto; - padding-right: 4px; -} - -.marker-item { - padding: 10px 12px; - border-radius: 12px; - background: rgba(33, 40, 60, 0.9); - display: flex; - justify-content: space-between; - align-items: center; -} - -.timeline-shell { - display: flex; - flex-direction: column; - gap: 16px; -} - -.timeline-surface { - min-height: 240px; - border-radius: 16px; - background: linear-gradient(180deg, rgba(25, 31, 44, 0.92) 0%, rgba(12, 16, 24, 0.96) 100%); - border: 1px solid rgba(255, 255, 255, 0.05); - padding: 18px 16px; - display: flex; - flex-direction: column; - gap: 18px; - position: relative; - overflow: hidden; -} - -.timeline-surface.timeline-tracklist { - background: linear-gradient(180deg, rgba(23, 29, 44, 0.94) 0%, rgba(10, 13, 20, 0.98) 100%); -} - -.timeline-surface.timeline-results { - gap: 14px; -} - -.timeline-track { - position: relative; - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px 18px; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: linear-gradient(135deg, rgba(44, 54, 88, 0.55) 0%, rgba(18, 22, 36, 0.9) 100%); - box-shadow: 0 24px 42px -32px rgba(6, 10, 20, 0.9); - overflow: hidden; -} - -.timeline-track::before { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - background: linear-gradient(0deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 55%); - opacity: 0.5; - mix-blend-mode: overlay; -} - -.timeline-track__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - position: relative; - z-index: 1; -} - -.timeline-track__title-group { - display: flex; - flex-direction: column; - gap: 4px; -} - -.timeline-track__title { - font-size: 1.05rem; - font-weight: 600; - letter-spacing: 0.04em; -} - -.timeline-track__subtitle { - font-size: 0.74rem; - letter-spacing: 0.08em; - opacity: 0.6; - text-transform: uppercase; -} - -.timeline-track__badge { - background: rgba(255, 196, 72, 0.2); - color: #fbd56a; - border: 1px solid rgba(255, 196, 72, 0.4); - border-radius: 999px; - padding: 6px 14px; - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.timeline-track[data-state="recording"] .timeline-track__badge { - background: rgba(47, 231, 163, 0.18); - border-color: rgba(47, 231, 163, 0.45); - color: #2fe7a3; -} - -.timeline-track__metrics { - display: flex; - flex-wrap: wrap; - gap: 12px 18px; - font-size: 0.78rem; - letter-spacing: 0.06em; - text-transform: uppercase; - opacity: 0.78; - position: relative; - z-index: 1; -} - -.timeline-track__metric { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.timeline-track__metric--inbound { - color: #fbd56a; -} - -.timeline-track__metric--recording { - color: #9ec5ff; -} - -.timeline-track[data-state="recording"] .timeline-track__metric--recording { - color: #2fe7a3; -} - -.timeline-track__waveform { - position: relative; - height: 96px; - border-radius: 12px; - background: repeating-linear-gradient( - 90deg, - rgba(255, 255, 255, 0.04) 0, - rgba(255, 255, 255, 0.04) 12px, - rgba(255, 255, 255, 0.02) 12px, - rgba(255, 255, 255, 0.02) 24px - ), - linear-gradient(180deg, rgba(13, 17, 26, 0.92) 0%, rgba(10, 13, 20, 0.95) 100%); - border: 1px solid rgba(255, 255, 255, 0.05); - overflow: hidden; -} - -.timeline-track__spectrogram { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - display: block; - pointer-events: none; - opacity: 1; - mix-blend-mode: screen; - filter: saturate(1.15) contrast(1.08); -} - -.timeline-track__waveform::after { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - background: repeating-linear-gradient(0deg, transparent 0, transparent 14px, rgba(255, 255, 255, 0.03) 14px, rgba(255, 255, 255, 0.03) 16px); - opacity: 0.4; -} - -.timeline-track__wavefill { - position: absolute; - inset: 0; - z-index: 2; - background: linear-gradient(180deg, rgba(47, 231, 163, 0.55) 0%, rgba(95, 142, 255, 0.4) 70%, rgba(95, 142, 255, 0.25) 100%); - transform-origin: center; - transform: scaleY(0.08); - opacity: 0.4; - transition: transform 140ms linear, opacity 160ms ease; - mix-blend-mode: screen; -} - -.timeline-track[data-state="recording"] .timeline-track__wavefill { - opacity: 0.72; -} - -.timeline-entry { - display: flex; - flex-direction: column; - gap: 6px; - padding: 12px 14px; - border-radius: 12px; - background: rgba(33, 40, 60, 0.9); - border: 1px solid rgba(255, 255, 255, 0.05); -} - -.timeline-entry-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.timeline-entry-label { - font-weight: 600; - letter-spacing: 0.04em; -} - -.upload-meta { - font-size: 0.75rem; - opacity: 0.7; -} - -.upload-status { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 0.7rem; - opacity: 0.78; - width: 100%; -} - -.upload-status-line { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.upload-status-line[data-status="uploaded"] { - color: #2fe7a3; -} - -.upload-status-line[data-status="error"] { - color: #ff5c69; -} - -.timeline-placeholder { - flex: 1; - min-height: 200px; - border-radius: 14px; - background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0, rgba(255, 255, 255, 0.035) 10px, rgba(255, 255, 255, 0.02) 10px, rgba(255, 255, 255, 0.02) 20px); - border: 1px solid rgba(255, 255, 255, 0.04); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.9rem; - color: rgba(255, 255, 255, 0.5); -} - -.session-tool--host { - display: flex; - flex-direction: column; - gap: 10px; -} - -.host-input-content { - display: inline-flex; - align-items: center; - gap: 14px; -} - -.host-input-toggle { - background: rgba(95, 142, 255, 0.18); - color: #9ec5ff; - border: 1px solid rgba(95, 142, 255, 0.36); - border-radius: 999px; - padding: 9px 18px; - font-size: 0.9rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: transform 120ms ease, background 120ms ease, color 120ms ease; -} - -.host-input-toggle.active { - background: rgba(47, 231, 163, 0.22); - border-color: rgba(47, 231, 163, 0.4); - color: #2fe7a3; -} - -.host-input-toggle:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.host-input-status { - font-size: 0.85rem; - letter-spacing: 0.05em; - opacity: 0.7; -} - -.host-input-status[data-state="active"] { - color: #2fe7a3; - opacity: 0.88; -} - -.host-input-error { - font-size: 0.75rem; - letter-spacing: 0.04em; - color: #ff6b88; - min-height: 16px; -} - -.cloud-sync-list { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 18px; -} - -.cloud-sync-list__row { - display: inline-flex; - align-items: center; - gap: 12px; - padding-right: 18px; -} - -.cloud-sync-list__label { - font-size: 0.78rem; - letter-spacing: 0.08em; - text-transform: uppercase; - opacity: 0.72; -} - -.cloud-sync-list__actions { - display: inline-flex; - align-items: center; - gap: 10px; -} - -.cloud-sync-list__button { - background: rgba(95, 142, 255, 0.18); - color: #a6c8ff; - border: 1px solid rgba(95, 142, 255, 0.32); - border-radius: 999px; - padding: 7px 14px; - font-size: 0.8rem; - letter-spacing: 0.07em; - text-transform: uppercase; - cursor: pointer; - transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease; -} - -.cloud-sync-list__button:hover { - transform: translateY(-1px); - background: rgba(95, 142, 255, 0.28); - color: #d7e6ff; -} - -.cloud-sync-list__button:disabled { - opacity: 0.45; - cursor: not-allowed; - transform: none; -} - -.cloud-sync-list__button[data-state='linked'] { - background: rgba(47, 231, 163, 0.22); - border-color: rgba(47, 231, 163, 0.38); - color: #2fe7a3; -} - -.cloud-sync-list__status { - font-size: 0.7rem; - letter-spacing: 0.06em; - opacity: 0.68; - text-transform: uppercase; -} - -.cloud-sync-list__status[data-state='linked'] { - color: #2fe7a3; - opacity: 0.9; -} - -.cloud-sync-list__hint { - font-size: 0.7rem; - letter-spacing: 0.04em; - opacity: 0.64; - min-height: 16px; - display: inline-block; - margin-right: 18px; -} - -.cloud-sync-list__hint-text { - display: block; -} - -.cloud-sync-token { - display: none; - align-items: center; - gap: 8px; - margin-top: 6px; - flex-wrap: wrap; -} - -.cloud-sync-token--visible { - display: flex; -} - -.cloud-sync-token__label { - font-size: 0.68rem; - letter-spacing: 0.06em; - text-transform: uppercase; - opacity: 0.6; -} - -.cloud-sync-token__input { - flex: 1 1 220px; - min-width: 220px; - background: rgba(7, 10, 18, 0.95); - border: 1px solid rgba(95, 142, 255, 0.45); - border-radius: 10px; - color: #f8fbff; - padding: 6px 10px; - font-family: inherit; -} - -.cloud-sync-token__input::placeholder { - color: rgba(255, 255, 255, 0.88); -} - -.cloud-sync-token__input:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.cloud-sync-token__guide { - display: none; - margin-top: 4px; - font-size: 0.68rem; - opacity: 0.62; -} - -.cloud-sync-token__guide--visible { - display: block; -} - -.cloud-sync-guide-link { - color: #a6c8ff; - text-decoration: underline; -} - -.cloud-sync-guide-link:hover { - color: #d7e6ff; -} - -.cloud-sync-list__hint:empty { - display: none; -} - -.cloud-sync-list__hint[data-variant='success'] { - color: #2fe7a3; - opacity: 0.82; -} - -.cloud-sync-list__hint[data-variant='warn'] { - color: #f2d95c; - opacity: 0.82; -} - -.cloud-sync-list__hint[data-variant='error'] { - color: #ff6b88; - opacity: 0.9; -} - -.cloud-sync-list--disk { - flex-direction: column; - align-items: flex-start; - gap: 10px; -} - -.cloud-sync-summary { - margin-top: 18px; - padding: 12px 14px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(10, 13, 23, 0.85); - display: grid; - gap: 8px; -} - -.cloud-sync-summary__item { - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.75); - position: relative; - padding-left: 16px; -} - -.cloud-sync-summary__item::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - width: 6px; - height: 6px; - border-radius: 50%; - transform: translateY(-50%); - background: rgba(255, 255, 255, 0.45); -} - -.cloud-sync-summary__item[data-state='ready'] { - color: #43c9a5; -} - -.cloud-sync-summary__item[data-state='ready']::before { - background: #43c9a5; -} - -.cloud-sync-summary__item[data-state='error'] { - color: #ff5c69; -} - -.cloud-sync-summary__item[data-state='error']::before { - background: #ff5c69; -} - -.cloud-sync-summary__item[data-state='disabled'] { - color: rgba(255, 255, 255, 0.45); -} - -.cloud-sync-progress { - margin-top: 12px; - padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 10px; - background: rgba(8, 10, 18, 0.75); - display: grid; - gap: 6px; -} - -.cloud-sync-progress__item { - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.7); -} - -.cloud-sync-progress__item[data-state='uploading'] { - color: #96c7ff; -} - -.cloud-sync-progress__item[data-state='pending'] { - color: #f2d95c; -} - -.cloud-sync-progress__item[data-state='error'] { - color: #ff8a84; -} - -.cloud-sync-progress__item[data-state='complete'] { - color: #2fe7a3; -} - -@media (max-width: 640px) { - .cloud-sync-list__row { - flex-direction: column; - align-items: flex-start; - gap: 8px; - padding-right: 0; - } - .cloud-sync-list__actions { - width: 100%; - justify-content: flex-start; - flex-wrap: wrap; - gap: 8px; - } - .cloud-sync-list__button { - flex: 0 0 auto; - } -} - -.podcast-footer { - padding: 16px 32px 24px; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - opacity: 0.6; -} - -.cloud-status { - display: inline-flex; - align-items: center; - gap: 10px; -} - -.cloud-status span { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.marker-badge { - padding: 4px 10px; - border-radius: 999px; - background: rgba(95, 142, 255, 0.22); - color: #9ec5ff; - font-size: 0.7rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.empty-state { - padding: 16px; - border-radius: 14px; - background: rgba(27, 33, 47, 0.9); - text-align: center; - font-size: 0.85rem; - opacity: 0.72; -} - -#podcast-room-gate { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: radial-gradient(circle at 10% 20%, rgba(19, 24, 36, 0.92) 0%, rgba(7, 9, 14, 0.96) 80%); - backdrop-filter: blur(18px); - color: #f5f7ff; - font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; - padding: 24px; -} - -.podcast-room-gate__panel { - width: min(480px, 100%); - border-radius: 24px; - padding: 32px; - background: rgba(12, 17, 27, 0.88); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 34px 68px -32px rgba(1, 5, 12, 0.9); - display: flex; - flex-direction: column; - gap: 18px; -} - -.podcast-room-gate__title { - margin: 0; - font-size: 1.6rem; - letter-spacing: 0.08em; -} - -.podcast-room-gate__subtitle { - margin: 0; - font-size: 0.95rem; - line-height: 1.5; - opacity: 0.72; -} - -.podcast-room-gate__form { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 8px; -} - -.podcast-room-gate__form label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 0.85rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.64); -} - -.podcast-room-gate__form input { - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(17, 24, 37, 0.92); - color: #f5f7ff; - padding: 12px 14px; - font-size: 1rem; - letter-spacing: 0.02em; - transition: border-color 120ms ease, background 120ms ease; -} - -.podcast-room-gate__form input:focus { - outline: none; - border-color: rgba(47, 231, 163, 0.4); - background: rgba(20, 31, 46, 0.96); -} - -.podcast-room-gate__actions { - display: flex; - gap: 12px; - margin-top: 12px; -} - -.podcast-room-gate__submit { - flex: 1; - border-radius: 999px; - border: 1px solid rgba(47, 231, 163, 0.5); - background: linear-gradient(120deg, rgba(47, 231, 163, 0.26), rgba(28, 182, 219, 0.28)); - color: #2fe7a3; - text-transform: uppercase; - font-size: 0.95rem; - letter-spacing: 0.1em; - padding: 12px 18px; - cursor: pointer; - transition: transform 120ms ease, box-shadow 120ms ease; -} - -.podcast-room-gate__submit:hover { - transform: translateY(-1px); - box-shadow: 0 14px 32px -18px rgba(47, 231, 163, 0.6); -} - -.podcast-room-gate__cancel { - flex: 0 0 auto; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(17, 24, 37, 0.7); - color: rgba(255, 255, 255, 0.68); - padding: 12px 18px; - font-size: 0.95rem; - letter-spacing: 0.06em; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.podcast-room-gate__cancel:hover { - background: rgba(19, 27, 41, 0.9); - color: rgba(255, 255, 255, 0.82); -} - -.podcast-room-gate__error { - min-height: 18px; - font-size: 0.8rem; - letter-spacing: 0.04em; - color: #ff6b88; -} - -.podcast-preflight-backdrop { - position: fixed; - inset: 0; - z-index: 10000; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: rgba(8, 10, 16, 0.88); - backdrop-filter: blur(14px); - color: #f5f7ff; - font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; -} - -.podcast-preflight-panel { - width: min(520px, 100%); - display: flex; - flex-direction: column; - gap: 18px; - padding: 28px 32px 32px; - border-radius: 22px; - background: rgba(13, 18, 28, 0.92); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 44px 88px -40px rgba(0, 0, 0, 0.85); -} - -.preflight-title { - margin: 0; - font-size: 1.45rem; - letter-spacing: 0.08em; -} - -.preflight-subtitle { - margin: 0; - font-size: 0.9rem; - opacity: 0.7; -} - -.preflight-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.preflight-row { - display: flex; - justify-content: space-between; - gap: 16px; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.07); - background: rgba(20, 26, 40, 0.78); - transition: border-color 120ms ease, background 120ms ease; -} - -.preflight-row[data-status='ready'] { - border-color: rgba(47, 231, 163, 0.45); - background: rgba(18, 32, 32, 0.8); -} - -.preflight-row[data-status='testing'] { - border-color: rgba(90, 169, 255, 0.4); - background: rgba(18, 25, 40, 0.82); -} - -.preflight-row[data-status='error'] { - border-color: rgba(255, 108, 136, 0.5); - background: rgba(32, 18, 24, 0.78); -} - -.preflight-row__info { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; -} - -.preflight-row__label { - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - font-size: 0.8rem; -} - -.preflight-row__description { - font-size: 0.85rem; - opacity: 0.7; -} - -.preflight-row__message { - font-size: 0.75rem; - opacity: 0.65; -} - -.preflight-row__controls { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 8px; - min-width: 140px; -} - -.preflight-row__status { - font-size: 0.75rem; - letter-spacing: 0.06em; - text-transform: uppercase; - opacity: 0.7; -} - -.preflight-row[data-status='ready'] .preflight-row__status { - color: #2fe7a3; - opacity: 0.85; -} - -.preflight-row[data-status='testing'] .preflight-row__status { - color: #65b6ff; - opacity: 0.9; -} - -.preflight-row[data-status='error'] .preflight-row__status { - color: #ff6c88; - opacity: 0.9; -} - -.preflight-row__action { - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(8, 12, 20, 0.9); - color: rgba(255, 255, 255, 0.82); - padding: 8px 16px; - font-size: 0.75rem; - letter-spacing: 0.08em; - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease, color 120ms ease; -} - -.preflight-row__action:hover:not(:disabled) { - border-color: rgba(47, 231, 163, 0.45); - color: #2fe7a3; -} - -.preflight-row__action:disabled { - opacity: 0.4; - cursor: wait; -} - -.preflight-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - margin-top: 10px; -} - -.preflight-primary { - border-radius: 999px; - border: 1px solid rgba(47, 231, 163, 0.5); - background: rgba(47, 231, 163, 0.18); - color: #2fe7a3; - padding: 12px 20px; - text-transform: uppercase; - letter-spacing: 0.1em; - font-size: 0.85rem; - cursor: pointer; - transition: background 120ms ease, border 120ms ease, color 120ms ease; -} - -.preflight-primary:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.preflight-primary:not(:disabled):hover { - background: rgba(47, 231, 163, 0.28); -} - -.preflight-secondary { - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(8, 12, 20, 0.72); - color: rgba(255, 255, 255, 0.65); - padding: 12px 18px; - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.8rem; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} - -.preflight-secondary:hover { - background: rgba(12, 18, 28, 0.86); - color: rgba(255, 255, 255, 0.85); -} - -.remote-overlay { - position: fixed; - inset: 0; - padding: 40px 28px; - display: none; - align-items: center; - justify-content: center; - z-index: 10020; - background: rgba(6, 8, 12, 0.82); - backdrop-filter: blur(12px); -} - -.remote-overlay[data-visible='true'] { - display: flex; -} - -.remote-overlay__panel { - width: min(760px, 96vw); - max-height: 92vh; - background: rgba(12, 17, 27, 0.94); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 20px; - box-shadow: 0 40px 80px -36px rgba(0, 0, 0, 0.85); - display: flex; - flex-direction: column; -} - -.remote-overlay__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 22px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -.remote-overlay__header h3 { - margin: 0; - font-size: 1.1rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.82); -} - -.remote-overlay__close { - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(8, 12, 20, 0.9); - color: rgba(255, 255, 255, 0.78); - padding: 8px 14px; - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; -} - -.remote-overlay__close:hover { - border-color: rgba(47, 231, 163, 0.4); - color: #2fe7a3; -} - -.remote-overlay__body { - flex: 1; - overflow-y: auto; - padding: 20px 22px; - display: flex; - flex-direction: column; - gap: 18px; -} - -.remote-overlay__empty { - font-size: 0.9rem; - opacity: 0.7; -} - -.remote-overlay__legacy { - width: 100%; - background: rgba(15, 21, 34, 0.92); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; - padding: 16px; - overflow: auto; - max-height: calc(90vh - 160px); -} - -.remote-overlay__legacy .controlCenterBox { - background: transparent; - box-shadow: none; -} - -.remote-overlay__legacy button { - cursor: pointer; -} - -body.podcast-studio-mode #chatModule { - position: relative !important; - right: auto !important; - bottom: auto !important; - top: auto !important; - left: auto !important; - width: 100% !important; - max-width: 100% !important; - height: auto !important; - margin: 0 !important; - max-height: 320px !important; - padding: 12px !important; - border: 1px solid rgba(255, 255, 255, 0.06) !important; - border-radius: 14px !important; - background: rgba(15, 22, 34, 0.85) !important; - box-shadow: none !important; - flex-direction: column !important; - gap: 12px !important; - z-index: auto !important; - overflow: hidden !important; -} - -body.podcast-studio-mode #chatModule:not(.hidden) { - display: flex !important; -} - -body.podcast-studio-mode #chatModule.hidden { - display: none !important; -} - -body.podcast-studio-mode #chatModule #chatBody { - position: relative !important; - top: auto !important; - bottom: auto !important; - height: auto !important; - max-height: 220px !important; - overflow-y: auto !important; - margin: 0 !important; - padding: 8px !important; - border-radius: 10px !important; - background: rgba(0, 0, 0, 0.2) !important; - border: 1px solid rgba(255, 255, 255, 0.05) !important; - box-shadow: inset 0 0 32px -26px rgba(0, 0, 0, 0.8) !important; -} - -body.podcast-studio-mode #chatModule #chatBody::-webkit-scrollbar { - width: 6px; -} - -body.podcast-studio-mode #chatModule #chatSendBar { - position: relative !important; - bottom: auto !important; - margin: 0 !important; - padding: 0 !important; -} - -body.podcast-studio-mode #chatModule #chatInput { - width: 100% !important; - margin-left: 0 !important; -} - -body.podcast-studio-mode #chatModule .message { - background: rgba(0, 0, 0, 0.25) !important; - border: 1px solid rgba(255, 255, 255, 0.04) !important; -} - -body.podcast-studio-mode #chatModuleButton { - display: none !important; -} +body.podcast-studio-mode { + background: radial-gradient(circle at 10% 20%, #191f2c 0%, #0b0d13 70%); + color: #f5f7ff; + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; + min-height: 100vh; + padding: 0; + margin: 0; +} + +body.podcast-studio-mode > :not(#podcast-root):not(#podcast-room-gate):not([data-podcast-overlay="true"]):not(script):not(link) { + display: none !important; +} + +#podcast-root { + display: flex; + flex-direction: column; + min-height: 100vh; + backdrop-filter: blur(14px); +} + +.podcast-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 32px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.podcast-header h1 { + font-size: 1.4rem; + margin: 0; + letter-spacing: 0.04em; +} + +.podcast-status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + padding: 6px 12px; + border-radius: 999px; + background: rgba(10, 194, 118, 0.12); + color: #2fe7a3; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.video-record-hint { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 0.02em; +} + +.video-iso-tip { + font-size: 0.72rem; + color: rgba(158, 197, 255, 0.6); + text-decoration: none; + letter-spacing: 0.02em; + padding: 2px 6px; + border-radius: 4px; + transition: color 150ms ease, background 150ms ease; +} + +.video-iso-tip:hover { + color: #9ec5ff; + background: rgba(95, 142, 255, 0.15); + text-decoration: underline; +} + +.podcast-main { + flex: 1; + display: grid; + grid-template-columns: 460px 1fr; + gap: 24px; + padding: 24px 32px 32px; + overflow: hidden; +} + +.podcast-roster { + display: flex; + flex-direction: column; + gap: 16px; +} + +.podcast-panel { + background: rgba(15, 20, 32, 0.78); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 18px; + padding: 20px; + box-shadow: 0 24px 48px -32px rgba(9, 11, 19, 0.8); +} + +.podcast-panel h2 { + margin: 0 0 12px; + font-size: 1.05rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.7); +} + +.roster-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.roster-item { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(23, 30, 46, 0.9); + border: 1px solid transparent; + transition: border-color 120ms ease; +} + +.roster-item[data-status="connected"] { + border-color: rgba(47, 231, 163, 0.3); +} + +.roster-item[data-status="connecting"] { + border-color: rgba(255, 196, 72, 0.3); +} + +.roster-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.roster-name { + font-weight: 600; + letter-spacing: 0.04em; +} + +.roster-id { + font-size: 0.75rem; + opacity: 0.6; +} + +.roster-role { + font-size: 0.7rem; + opacity: 0.55; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.roster-item__media-row { + display: flex; + align-items: center; + gap: 12px; +} + +.roster-item__video-thumb { + width: 80px; + height: 45px; + object-fit: cover; + border-radius: 6px; + background: #1a1a2e; + flex-shrink: 0; +} + +.roster-item__video-thumb[data-no-video="true"] { + display: none; +} + +.meter-bar { + position: relative; + width: 70px; + flex-shrink: 0; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.meter-bar-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 1%; + transform-origin: left center; + background: linear-gradient(90deg, #29f19c 0%, #f2d95c 70%, #ff5c69 100%); + transition: width 100ms linear; +} + +.roster-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.roster-action-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.roster-action-button { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.85); + border-radius: 999px; + padding: 6px 14px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.roster-action-button:hover { + background: rgba(47, 231, 163, 0.16); + border-color: rgba(47, 231, 163, 0.4); + color: #2fe7a3; +} + +.roster-action-button[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +.roster-action-button--drive[data-state="active"] { + background: rgba(47, 231, 163, 0.22); + border-color: rgba(47, 231, 163, 0.45); + color: #2fe7a3; +} + +.roster-drive-status { + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.58; + color: rgba(255, 255, 255, 0.75); + align-self: center; +} + +.roster-drive-status[data-state="uploading"], +.roster-drive-status[data-state="pending"] { + color: #2fe7a3; + opacity: 0.85; +} + +.roster-drive-status[data-state="done"] { + color: #8fd8ff; + opacity: 0.9; +} + +.roster-drive-status[data-state="error"] { + color: #ff7d88; + opacity: 0.95; +} + +.podcast-console { + display: flex; + flex-direction: column; + gap: 20px; +} + +.podcast-console-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + align-items: start; +} + +.console-grid__span-2 { + grid-column: 1 / -1; +} + +.session-tools__grid { + display: flex; + flex-wrap: wrap; + gap: 22px; + align-items: flex-start; +} + +.session-tool { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border-radius: 14px; + background: rgba(22, 28, 44, 0.65); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: inset 0 0 28px -24px rgba(0, 0, 0, 0.9); + flex: 0 0 auto; + min-width: 220px; + max-width: 100%; +} + +.session-tool__title { + margin: 0; + font-size: 0.95rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.68); +} + + +.session-info { + opacity: 0.6; + font-size: 0.85rem; + letter-spacing: 0.05em; + flex: 0 0 auto; + text-align: right; +} + +.session-tool--control .session-info { + margin-left: 18px; +} + +.session-tool--cloud { + gap: 10px; +} + +.session-tool--control .transport-strip { + display: inline-flex; + align-items: center; + gap: 16px; + margin-top: 4px; +} + +.session-tool--control .transport-buttons { + display: inline-flex; + gap: 12px; +} + +@media (max-width: 900px) { + .podcast-console-grid { + grid-template-columns: 1fr; + } + .session-tools__grid { + flex-direction: column; + } +} + +.invite-panel p { + margin: 0 0 12px; + font-size: 0.85rem; + opacity: 0.7; +} + +.invite-link-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + margin-bottom: 8px; +} + +.invite-link-input { + background: rgba(8, 12, 22, 0.88); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + color: rgba(255, 255, 255, 0.92); + padding: 10px 14px; + font-size: 0.9rem; + letter-spacing: 0.02em; +} + +.invite-link-input[data-state='placeholder'] { + color: rgba(255, 255, 255, 0.45); + font-style: italic; +} + +.invite-link-copy { + background: rgba(47, 231, 163, 0.18); + border: 1px solid rgba(47, 231, 163, 0.4); + border-radius: 12px; + color: #2fe7a3; + padding: 10px 16px; + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.invite-link-copy:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.invite-link-copy:not(:disabled):hover { + background: rgba(47, 231, 163, 0.28); +} + +.invite-status { + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.65; + margin-bottom: 12px; +} + +.invite-status[data-variant='success'] { + color: #2fe7a3; + opacity: 0.85; +} + +.invite-status[data-variant='warning'] { + color: #f2d95c; + opacity: 0.85; +} + +.invite-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.invite-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + opacity: 0.85; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 8px 10px; + border-radius: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease; +} + +.invite-option:hover { + background: rgba(47, 231, 163, 0.12); + border-color: rgba(47, 231, 163, 0.28); + opacity: 1; +} + +.invite-option__checkbox { + width: 16px; + height: 16px; + accent-color: #2fe7a3; +} + +.invite-option__label { + flex: 1; + color: rgba(255, 255, 255, 0.9); +} + +.chat-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.chat-panel__header h2 { + margin: 0; +} + +.chat-panel__actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.chat-panel__action { + background: rgba(95, 142, 255, 0.18); + color: #a6c8ff; + border: 1px solid rgba(95, 142, 255, 0.32); + border-radius: 999px; + padding: 6px 12px; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, opacity 120ms ease, transform 120ms ease; +} + +.chat-panel__action:hover { + transform: translateY(-1px); + background: rgba(95, 142, 255, 0.26); + color: #d7e6ff; +} + +.chat-panel__toggle { + background: rgba(47, 231, 163, 0.18); + border-color: rgba(47, 231, 163, 0.36); + color: #2fe7a3; +} + +.chat-panel[data-collapsed='true'] .chat-panel__toggle { + background: rgba(95, 142, 255, 0.22); + border-color: rgba(95, 142, 255, 0.34); + color: #a6c8ff; +} + +.chat-panel__body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-panel__collapsed-hint { + display: none; + font-size: 0.78rem; + opacity: 0.65; + letter-spacing: 0.05em; +} + +.chat-panel[data-collapsed='true'] .chat-panel__body { + display: none; +} + +.chat-panel[data-collapsed='true'] .chat-panel__collapsed-hint { + display: block; +} + +/* Generic collapsible panel styles */ +.podcast-panel[data-collapsible='true'] { + position: relative; +} + +.panel-collapse-toggle { + position: absolute; + top: 12px; + right: 12px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.5); + font-size: 0.65rem; + width: 20px; + height: 20px; + padding: 0; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s, background 0.15s, border-color 0.15s; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + z-index: 1; +} + +.panel-collapse-toggle:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); +} + +.podcast-panel[data-collapsed='true'] > *:not(.panel-collapse-toggle):not(h2):not(.podcast-panel__header):not(.session-tool__title) { + display: none; +} + +.podcast-panel[data-collapsed='true'] .panel-collapse-toggle { + color: rgba(255, 255, 255, 0.7); +} + +.chat-panel__actions .panel-collapse-toggle { + position: static; +} + +.chat-panel__empty { + font-size: 0.85rem; + opacity: 0.65; +} + +.transport-strip { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.transport-buttons { + display: inline-flex; + gap: 12px; + flex-wrap: wrap; +} + +.transport-buttons button { + background: rgba(47, 231, 163, 0.18); + color: #2fe7a3; + border: 1px solid rgba(47, 231, 163, 0.4); + border-radius: 999px; + padding: 10px 18px; + font-size: 0.95rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease; + white-space: nowrap; +} + +.transport-buttons button.recording { + background: rgba(255, 92, 105, 0.18); + border-color: rgba(255, 92, 105, 0.4); + color: #fff8fb; + text-shadow: 0 0 6px rgba(138, 21, 30, 0.45); +} + +.transport-buttons button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Video record button (Record Group) - matches transport-buttons styling */ +.transport-strip--video button { + background: rgba(95, 142, 255, 0.18); + color: #a6c8ff; + border: 1px solid rgba(95, 142, 255, 0.4); + border-radius: 999px; + padding: 10px 18px; + font-size: 0.95rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease; + white-space: nowrap; +} + +.transport-strip--video button:hover { + background: rgba(95, 142, 255, 0.28); + color: #d7e6ff; +} + +.transport-disk { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + background: rgba(13, 16, 26, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + min-width: 220px; +} + +.transport-disk__header { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.72); +} + +.transport-disk__toggle { + background: rgba(47, 231, 163, 0.12); + border: 1px solid rgba(47, 231, 163, 0.35); + border-radius: 999px; + color: #2fe7a3; + padding: 8px 16px; + font-size: 0.85rem; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + white-space: nowrap; +} + +.transport-disk__toggle[data-state='enabled'] { + background: rgba(47, 231, 163, 0.25); + color: #073b26; +} + +.transport-disk__toggle:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.transport-disk__status { + font-size: 0.72rem; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.7); +} + +.transport-disk__status[data-state='error'] { + color: #f86e7b; +} + +.transport-disk__status[data-state='ready'] { + color: #2fe7a3; +} + +.transport-disk--panel { + width: 100%; +} + +.transport-disk__hint { + margin-top: 8px; + font-size: 0.78rem; + opacity: 0.68; +} + +.recording-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.session-recording-status { + font-size: 0.82rem; + padding: 6px 12px; + border-left: 2px solid rgba(255, 255, 255, 0.18); + border-radius: 10px; + background: rgba(12, 15, 26, 0.9); + color: rgba(255, 255, 255, 0.78); +} + +.session-recording-status[data-state='arming'], +.session-recording-status[data-state='armed'] { + border-color: rgba(255, 204, 102, 0.65); + color: #ffcc66; +} + +.session-recording-status[data-state='active'] { + border-color: rgba(47, 231, 163, 0.65); + color: #2fe7a3; +} + +.session-recording-status[data-state='stopping'] { + border-color: rgba(255, 92, 105, 0.65); + color: #ff5c69; +} + +.session-recording-status[data-state='error'] { + border-color: rgba(255, 92, 105, 0.9); + color: #ff5c69; +} + +.transport-disk__folder { + align-self: flex-start; + background: rgba(95, 142, 255, 0.18); + border: 1px solid rgba(95, 142, 255, 0.35); + border-radius: 10px; + color: #a6c8ff; + padding: 6px 12px; + font-size: 0.78rem; + letter-spacing: 0.05em; + cursor: pointer; + white-space: nowrap; +} + +.transport-disk__folder:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.marker-log { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 160px; + overflow-y: auto; + padding-right: 4px; +} + +.marker-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.marker-item { + padding: 10px 12px; + border-radius: 12px; + background: rgba(33, 40, 60, 0.9); + display: flex; + justify-content: space-between; + align-items: center; +} + +.timeline-shell { + display: flex; + flex-direction: column; + gap: 16px; +} + +.timeline-surface { + min-height: 240px; + border-radius: 16px; + background: linear-gradient(180deg, rgba(25, 31, 44, 0.92) 0%, rgba(12, 16, 24, 0.96) 100%); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 18px 16px; + display: flex; + flex-direction: column; + gap: 18px; + position: relative; + overflow: hidden; +} + +.timeline-surface.timeline-tracklist { + background: linear-gradient(180deg, rgba(23, 29, 44, 0.94) 0%, rgba(10, 13, 20, 0.98) 100%); +} + +.timeline-surface.timeline-results { + gap: 14px; +} + +.timeline-track { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: linear-gradient(135deg, rgba(44, 54, 88, 0.55) 0%, rgba(18, 22, 36, 0.9) 100%); + box-shadow: 0 24px 42px -32px rgba(6, 10, 20, 0.9); + overflow: hidden; +} + +.timeline-track::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(0deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 55%); + opacity: 0.5; + mix-blend-mode: overlay; +} + +.timeline-track__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + position: relative; + z-index: 1; +} + +.timeline-track__title-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeline-track__title { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.timeline-track__subtitle { + font-size: 0.74rem; + letter-spacing: 0.08em; + opacity: 0.6; + text-transform: uppercase; +} + +.timeline-track__badge { + background: rgba(255, 196, 72, 0.2); + color: #fbd56a; + border: 1px solid rgba(255, 196, 72, 0.4); + border-radius: 999px; + padding: 6px 14px; + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.timeline-track[data-state="recording"] .timeline-track__badge { + background: rgba(47, 231, 163, 0.18); + border-color: rgba(47, 231, 163, 0.45); + color: #2fe7a3; +} + +.timeline-track__metrics { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.78; + position: relative; + z-index: 1; +} + +.timeline-track__metric { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.timeline-track__metric--inbound { + color: #fbd56a; +} + +.timeline-track__metric--recording { + color: #9ec5ff; +} + +.timeline-track[data-state="recording"] .timeline-track__metric--recording { + color: #2fe7a3; +} + +.timeline-track__waveform { + position: relative; + height: 96px; + border-radius: 12px; + background: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.04) 0, + rgba(255, 255, 255, 0.04) 12px, + rgba(255, 255, 255, 0.02) 12px, + rgba(255, 255, 255, 0.02) 24px + ), + linear-gradient(180deg, rgba(13, 17, 26, 0.92) 0%, rgba(10, 13, 20, 0.95) 100%); + border: 1px solid rgba(255, 255, 255, 0.05); + overflow: hidden; +} + +.timeline-track__spectrogram { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + pointer-events: none; + opacity: 1; + mix-blend-mode: screen; + filter: saturate(1.15) contrast(1.08); +} + +.timeline-track__waveform::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(0deg, transparent 0, transparent 14px, rgba(255, 255, 255, 0.03) 14px, rgba(255, 255, 255, 0.03) 16px); + opacity: 0.4; +} + +.timeline-track__wavefill { + position: absolute; + inset: 0; + z-index: 2; + background: linear-gradient(180deg, rgba(47, 231, 163, 0.55) 0%, rgba(95, 142, 255, 0.4) 70%, rgba(95, 142, 255, 0.25) 100%); + transform-origin: center; + transform: scaleY(0.08); + opacity: 0.4; + transition: transform 140ms linear, opacity 160ms ease; + mix-blend-mode: screen; +} + +.timeline-track[data-state="recording"] .timeline-track__wavefill { + opacity: 0.72; +} + +.timeline-entry { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(33, 40, 60, 0.9); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.timeline-entry-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.timeline-entry-label { + font-weight: 600; + letter-spacing: 0.04em; +} + +.upload-meta { + font-size: 0.75rem; + opacity: 0.7; +} + +.upload-status { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.7rem; + opacity: 0.78; + width: 100%; +} + +.upload-status-line { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.upload-status-line[data-status="uploaded"] { + color: #2fe7a3; +} + +.upload-status-line[data-status="error"] { + color: #ff5c69; +} + +.timeline-placeholder { + flex: 1; + min-height: 200px; + border-radius: 14px; + background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0, rgba(255, 255, 255, 0.035) 10px, rgba(255, 255, 255, 0.02) 10px, rgba(255, 255, 255, 0.02) 20px); + border: 1px solid rgba(255, 255, 255, 0.04); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.5); +} + +.host-panel { + display: flex; + flex-direction: column; + gap: 10px; +} + +.host-input-content { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.host-input-toggle { + background: rgba(95, 142, 255, 0.18); + color: #9ec5ff; + border: 1px solid rgba(95, 142, 255, 0.36); + border-radius: 999px; + padding: 9px 18px; + font-size: 0.9rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease, color 120ms ease; + white-space: nowrap; +} + +.host-input-toggle.active { + background: rgba(47, 231, 163, 0.22); + border-color: rgba(47, 231, 163, 0.4); + color: #2fe7a3; +} + +.host-input-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.host-mute-toggle { + background: rgba(95, 142, 255, 0.12); + color: #9ec5ff; + border: 1px solid rgba(95, 142, 255, 0.25); + border-radius: 999px; + padding: 6px 14px; + font-size: 0.8rem; + letter-spacing: 0.06em; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + white-space: nowrap; +} + +.host-mute-toggle:hover:not(:disabled) { + background: rgba(95, 142, 255, 0.2); +} + +.host-mute-toggle:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.host-mute-toggle.muted { + background: rgba(255, 107, 136, 0.18); + border-color: rgba(255, 107, 136, 0.35); + color: #ff6b88; +} + +.host-input-status { + font-size: 0.85rem; + letter-spacing: 0.05em; + opacity: 0.7; +} + +.host-input-status[data-state="active"] { + color: #2fe7a3; + opacity: 0.88; +} + +.host-input-error { + font-size: 0.75rem; + letter-spacing: 0.04em; + color: #ff6b88; + min-height: 16px; +} + +.cloud-sync-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; +} + +.cloud-sync-list__row { + display: inline-flex; + align-items: center; + gap: 12px; + padding-right: 18px; +} + +.cloud-sync-list__label { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.72; +} + +.cloud-sync-list__actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.cloud-sync-list__button { + background: rgba(95, 142, 255, 0.18); + color: #a6c8ff; + border: 1px solid rgba(95, 142, 255, 0.32); + border-radius: 999px; + padding: 7px 14px; + font-size: 0.8rem; + letter-spacing: 0.07em; + text-transform: uppercase; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease; + white-space: nowrap; +} + +.cloud-sync-list__button:hover { + transform: translateY(-1px); + background: rgba(95, 142, 255, 0.28); + color: #d7e6ff; +} + +.cloud-sync-list__button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.cloud-sync-list__button[data-state='linked'] { + background: rgba(47, 231, 163, 0.22); + border-color: rgba(47, 231, 163, 0.38); + color: #2fe7a3; +} + +.cloud-sync-list__status { + font-size: 0.7rem; + letter-spacing: 0.06em; + opacity: 0.68; + text-transform: uppercase; +} + +.cloud-sync-list__status[data-state='linked'] { + color: #2fe7a3; + opacity: 0.9; +} + +.cloud-sync-list__hint { + font-size: 0.7rem; + letter-spacing: 0.04em; + opacity: 0.64; + min-height: 16px; + display: inline-block; + margin-right: 18px; +} + +.cloud-sync-list__hint-text { + display: block; +} + +.cloud-sync-token { + display: none; + align-items: center; + gap: 8px; + margin-top: 6px; + flex-wrap: wrap; +} + +.cloud-sync-token--visible { + display: flex; +} + +.cloud-sync-token__label { + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.6; +} + +.cloud-sync-token__input { + flex: 1 1 220px; + min-width: 220px; + background: rgba(7, 10, 18, 0.95); + border: 1px solid rgba(95, 142, 255, 0.45); + border-radius: 10px; + color: #f8fbff; + padding: 6px 10px; + font-family: inherit; +} + +.cloud-sync-token__input::placeholder { + color: rgba(255, 255, 255, 0.88); +} + +.cloud-sync-token__input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.cloud-sync-token__guide { + display: none; + margin-top: 4px; + font-size: 0.68rem; + opacity: 0.62; +} + +.cloud-sync-token__guide--visible { + display: block; +} + +.cloud-sync-guide-link { + color: #a6c8ff; + text-decoration: underline; +} + +.cloud-sync-guide-link:hover { + color: #d7e6ff; +} + +.cloud-sync-list__hint:empty { + display: none; +} + +.cloud-sync-list__hint[data-variant='success'] { + color: #2fe7a3; + opacity: 0.82; +} + +.cloud-sync-list__hint[data-variant='warn'] { + color: #f2d95c; + opacity: 0.82; +} + +.cloud-sync-list__hint[data-variant='error'] { + color: #ff6b88; + opacity: 0.9; +} + +.cloud-sync-list--disk { + flex-direction: row; + align-items: center; + gap: 12px; +} + +.cloud-sync-summary { + margin-top: 18px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(10, 13, 23, 0.85); + display: grid; + gap: 8px; +} + +.cloud-sync-summary__item { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.75); + position: relative; + padding-left: 16px; +} + +.cloud-sync-summary__item::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.45); +} + +.cloud-sync-summary__item[data-state='ready'] { + color: #43c9a5; +} + +.cloud-sync-summary__item[data-state='ready']::before { + background: #43c9a5; +} + +.cloud-sync-summary__item[data-state='error'] { + color: #ff5c69; +} + +.cloud-sync-summary__item[data-state='error']::before { + background: #ff5c69; +} + +.cloud-sync-summary__item[data-state='disabled'] { + color: rgba(255, 255, 255, 0.45); +} + +/* ISO Recording Destinations - unified section */ +.session-tool--iso-config { + gap: 12px; +} + +.iso-config-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.iso-config-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.iso-config-row:last-child { + border-bottom: none; +} + +.iso-config-row__label { + font-size: 0.82rem; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.8); + min-width: 90px; + flex-shrink: 0; +} + +.iso-config-row__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.iso-config-row__button { + background: rgba(47, 231, 163, 0.15); + color: #2fe7a3; + border: 1px solid rgba(47, 231, 163, 0.35); + border-radius: 999px; + padding: 5px 12px; + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + white-space: nowrap; +} + +.iso-config-row__button:hover:not(:disabled) { + background: rgba(47, 231, 163, 0.25); +} + +.iso-config-row__button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.iso-config-row__button[data-state='linked'], +.iso-config-row__button[data-state='enabled'] { + background: rgba(47, 231, 163, 0.3); + color: #1a3d2e; +} + +.iso-config-row__button--secondary { + background: rgba(95, 142, 255, 0.15); + color: #9ec5ff; + border-color: rgba(95, 142, 255, 0.3); +} + +.iso-config-row__button--secondary:hover:not(:disabled) { + background: rgba(95, 142, 255, 0.25); +} + +.iso-config-row__status { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + letter-spacing: 0.03em; +} + +.iso-config-row__status[data-state='ready'], +.iso-config-row__status[data-state='linked'] { + color: #2fe7a3; +} + +.iso-config-row__status[data-state='error'] { + color: #ff6b88; +} + +.iso-config-row__hint { + margin-top: 4px; + padding-left: 102px; +} + +.iso-config-summary { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgba(8, 10, 18, 0.7); +} + +.iso-config-summary__item { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); +} + +.iso-config-summary__item[data-state='ready'] { + color: #2fe7a3; +} + +.iso-config-summary__item[data-state='error'] { + color: #ff6b88; +} + +@media (max-width: 640px) { + .cloud-sync-list__row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding-right: 0; + } + .cloud-sync-list__actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px; + } + .cloud-sync-list__button { + flex: 0 0 auto; + } +} + +.podcast-footer { + padding: 16px 32px 24px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + opacity: 0.6; +} + +.cloud-status { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.cloud-status span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.marker-badge { + padding: 4px 10px; + border-radius: 999px; + background: rgba(95, 142, 255, 0.22); + color: #9ec5ff; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.empty-state { + padding: 16px; + border-radius: 14px; + background: rgba(27, 33, 47, 0.9); + text-align: center; + font-size: 0.85rem; + opacity: 0.72; +} + +#podcast-room-gate { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at 10% 20%, rgba(19, 24, 36, 0.92) 0%, rgba(7, 9, 14, 0.96) 80%); + backdrop-filter: blur(18px); + color: #f5f7ff; + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; + padding: 24px; +} + +.podcast-room-gate__panel { + width: min(480px, 100%); + border-radius: 24px; + padding: 32px; + background: rgba(12, 17, 27, 0.88); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 34px 68px -32px rgba(1, 5, 12, 0.9); + display: flex; + flex-direction: column; + gap: 18px; +} + +.podcast-room-gate__title { + margin: 0; + font-size: 1.6rem; + letter-spacing: 0.08em; +} + +.podcast-room-gate__subtitle { + margin: 0; + font-size: 0.95rem; + line-height: 1.5; + opacity: 0.72; +} + +.podcast-room-gate__form { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 8px; +} + +.podcast-room-gate__form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.64); +} + +.podcast-room-gate__form input { + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(17, 24, 37, 0.92); + color: #f5f7ff; + padding: 12px 14px; + font-size: 1rem; + letter-spacing: 0.02em; + transition: border-color 120ms ease, background 120ms ease; +} + +.podcast-room-gate__form input:focus { + outline: none; + border-color: rgba(47, 231, 163, 0.4); + background: rgba(20, 31, 46, 0.96); +} + +.podcast-room-gate__actions { + display: flex; + gap: 12px; + margin-top: 12px; +} + +.podcast-room-gate__submit { + flex: 1; + border-radius: 999px; + border: 1px solid rgba(47, 231, 163, 0.5); + background: linear-gradient(120deg, rgba(47, 231, 163, 0.26), rgba(28, 182, 219, 0.28)); + color: #2fe7a3; + text-transform: uppercase; + font-size: 0.95rem; + letter-spacing: 0.1em; + padding: 12px 18px; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.podcast-room-gate__submit:hover { + transform: translateY(-1px); + box-shadow: 0 14px 32px -18px rgba(47, 231, 163, 0.6); +} + +.podcast-room-gate__cancel { + flex: 0 0 auto; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(17, 24, 37, 0.7); + color: rgba(255, 255, 255, 0.68); + padding: 12px 18px; + font-size: 0.95rem; + letter-spacing: 0.06em; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.podcast-room-gate__cancel:hover { + background: rgba(19, 27, 41, 0.9); + color: rgba(255, 255, 255, 0.82); +} + +.podcast-room-gate__error { + min-height: 18px; + font-size: 0.8rem; + letter-spacing: 0.04em; + color: #ff6b88; +} + +.podcast-preflight-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(8, 10, 16, 0.88); + backdrop-filter: blur(14px); + color: #f5f7ff; + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; +} + +.podcast-preflight-panel { + width: min(520px, 100%); + display: flex; + flex-direction: column; + gap: 18px; + padding: 28px 32px 32px; + border-radius: 22px; + background: rgba(13, 18, 28, 0.92); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 44px 88px -40px rgba(0, 0, 0, 0.85); +} + +.preflight-title { + margin: 0; + font-size: 1.45rem; + letter-spacing: 0.08em; +} + +.preflight-subtitle { + margin: 0; + font-size: 0.9rem; + opacity: 0.7; +} + +.preflight-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.preflight-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(20, 26, 40, 0.78); + transition: border-color 120ms ease, background 120ms ease; +} + +.preflight-row[data-status='ready'] { + border-color: rgba(47, 231, 163, 0.45); + background: rgba(18, 32, 32, 0.8); +} + +.preflight-row[data-status='testing'] { + border-color: rgba(90, 169, 255, 0.4); + background: rgba(18, 25, 40, 0.82); +} + +.preflight-row[data-status='error'] { + border-color: rgba(255, 108, 136, 0.5); + background: rgba(32, 18, 24, 0.78); +} + +.preflight-row__info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.preflight-row__label { + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.8rem; +} + +.preflight-row__description { + font-size: 0.85rem; + opacity: 0.7; +} + +.preflight-row__message { + font-size: 0.75rem; + opacity: 0.65; +} + +.preflight-row__controls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 140px; +} + +.preflight-row__status { + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.7; +} + +.preflight-row[data-status='ready'] .preflight-row__status { + color: #2fe7a3; + opacity: 0.85; +} + +.preflight-row[data-status='testing'] .preflight-row__status { + color: #65b6ff; + opacity: 0.9; +} + +.preflight-row[data-status='error'] .preflight-row__status { + color: #ff6c88; + opacity: 0.9; +} + +.preflight-row__action { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(8, 12, 20, 0.9); + color: rgba(255, 255, 255, 0.82); + padding: 8px 16px; + font-size: 0.75rem; + letter-spacing: 0.08em; + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease, color 120ms ease; +} + +.preflight-row__action:hover:not(:disabled) { + border-color: rgba(47, 231, 163, 0.45); + color: #2fe7a3; +} + +.preflight-row__action:disabled { + opacity: 0.4; + cursor: wait; +} + +.preflight-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 10px; +} + +.preflight-primary { + border-radius: 999px; + border: 1px solid rgba(47, 231, 163, 0.5); + background: rgba(47, 231, 163, 0.18); + color: #2fe7a3; + padding: 12px 20px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.85rem; + cursor: pointer; + transition: background 120ms ease, border 120ms ease, color 120ms ease; +} + +.preflight-primary:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.preflight-primary:not(:disabled):hover { + background: rgba(47, 231, 163, 0.28); +} + +.preflight-secondary { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(8, 12, 20, 0.72); + color: rgba(255, 255, 255, 0.65); + padding: 12px 18px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.8rem; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.preflight-secondary:hover { + background: rgba(12, 18, 28, 0.86); + color: rgba(255, 255, 255, 0.85); +} + +.remote-overlay { + position: fixed; + inset: 0; + padding: 40px 28px; + display: none; + align-items: center; + justify-content: center; + z-index: 10020; + background: rgba(6, 8, 12, 0.82); + backdrop-filter: blur(12px); +} + +.remote-overlay[data-visible='true'] { + display: flex; +} + +.remote-overlay__panel { + width: min(760px, 96vw); + max-height: 92vh; + background: rgba(12, 17, 27, 0.94); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + box-shadow: 0 40px 80px -36px rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; +} + +.remote-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.remote-overlay__header h3 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.82); +} + +.remote-overlay__close { + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(8, 12, 20, 0.9); + color: rgba(255, 255, 255, 0.78); + padding: 8px 14px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.remote-overlay__close:hover { + border-color: rgba(47, 231, 163, 0.4); + color: #2fe7a3; +} + +.remote-overlay__body { + flex: 1; + overflow-y: auto; + padding: 20px 22px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.remote-overlay__empty { + font-size: 0.9rem; + opacity: 0.7; +} + +.remote-overlay__legacy { + width: 100%; + background: rgba(15, 21, 34, 0.92); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 16px; + overflow: auto; + max-height: calc(90vh - 160px); +} + +.remote-overlay__legacy .controlCenterBox { + background: transparent; + box-shadow: none; +} + +.remote-overlay__legacy button { + cursor: pointer; +} + +body.podcast-studio-mode #chatModule { + position: relative !important; + right: auto !important; + bottom: auto !important; + top: auto !important; + left: auto !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + margin: 0 !important; + max-height: 320px !important; + padding: 12px !important; + border: 1px solid rgba(255, 255, 255, 0.06) !important; + border-radius: 14px !important; + background: rgba(15, 22, 34, 0.85) !important; + box-shadow: none !important; + flex-direction: column !important; + gap: 12px !important; + z-index: auto !important; + overflow: hidden !important; +} + +body.podcast-studio-mode #chatModule:not(.hidden) { + display: flex !important; +} + +body.podcast-studio-mode #chatModule.hidden { + display: none !important; +} + +body.podcast-studio-mode #chatModule #chatBody { + position: relative !important; + top: auto !important; + bottom: auto !important; + height: auto !important; + max-height: 220px !important; + overflow-y: auto !important; + margin: 0 !important; + padding: 8px !important; + border-radius: 10px !important; + background: rgba(0, 0, 0, 0.2) !important; + border: 1px solid rgba(255, 255, 255, 0.05) !important; + box-shadow: inset 0 0 32px -26px rgba(0, 0, 0, 0.8) !important; +} + +body.podcast-studio-mode #chatModule #chatBody::-webkit-scrollbar { + width: 6px; +} + +body.podcast-studio-mode #chatModule #chatSendBar { + position: relative !important; + bottom: auto !important; + margin: 0 !important; + padding: 0 !important; +} + +body.podcast-studio-mode #chatModule #chatInput { + width: 100% !important; + margin-left: 0 !important; +} + +body.podcast-studio-mode #chatModule .message { + background: rgba(0, 0, 0, 0.25) !important; + border: 1px solid rgba(255, 255, 255, 0.04) !important; +} + +body.podcast-studio-mode #chatModuleButton { + display: none !important; +} + +/* Help link in footer */ +.podcast-help-link { + background: none; + border: none; + color: inherit; + font: inherit; + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + padding: 0; + margin: 0; + opacity: 0.9; + transition: opacity 0.15s ease; +} + +.podcast-help-link:hover { + opacity: 1; + text-decoration-style: solid; +} + +/* Help modal overlay */ +.help-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.help-overlay[data-visible="true"] { + opacity: 1; + pointer-events: auto; +} + +.help-overlay__panel { + background: linear-gradient(145deg, #1e2433, #151a26); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + transform: translateY(20px); + transition: transform 0.2s ease; +} + +.help-overlay[data-visible="true"] .help-overlay__panel { + transform: translateY(0); +} + +.help-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.help-overlay__title { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: #f5f7ff; +} + +.help-overlay__close { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 1.2rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s ease; +} + +.help-overlay__close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.help-overlay__content { + flex: 1; + overflow-y: auto; + padding: 16px 24px 24px; +} + +/* Help sections (collapsible) */ +.help-section { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + margin-bottom: 10px; + background: rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.help-section[open] { + background: rgba(0, 0, 0, 0.3); +} + +.help-section__title { + padding: 14px 16px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + color: #e0e4f0; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + transition: background 0.15s ease; +} + +.help-section__title::-webkit-details-marker { + display: none; +} + +.help-section__title::after { + content: '+'; + font-size: 1.1rem; + color: rgba(255, 255, 255, 0.4); + transition: transform 0.2s ease; +} + +.help-section[open] .help-section__title::after { + content: '−'; +} + +.help-section__title:hover { + background: rgba(255, 255, 255, 0.05); +} + +.help-section__body { + padding: 0 16px 16px; + font-size: 0.88rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.75); +} + +.help-section__body p { + margin: 0 0 12px; +} + +.help-section__body p:last-child { + margin-bottom: 0; +} + +.help-section__body ul { + margin: 0 0 12px; + padding-left: 20px; +} + +.help-section__body li { + margin-bottom: 6px; +} + +.help-section__body strong { + color: #e0e4f0; +} + +.help-section__body code { + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85em; + font-family: 'Consolas', 'Monaco', monospace; +} + +.help-section__body a { + color: #6eb5ff; + text-decoration: none; +} + +.help-section__body a:hover { + text-decoration: underline; +} diff --git a/podcast/studio.js b/podcast/studio.js index 935e971..846ed7e 100644 --- a/podcast/studio.js +++ b/podcast/studio.js @@ -1,4328 +1,4814 @@ -import { - waitForLegacySession, - levelBus, - LEVEL_EVENT, - MultiTrackRecorder, - CloudUploadCoordinator, - bridgeLegacyMeters, - monitorTrackLevel, -} from '../core/index.js'; - -const STUDIO_ROOT_ID = 'podcast-root'; -const ROSTER_REFRESH_MS = 1500; -const PREFLIGHT_STORAGE_KEY = 'podcastStudio.preflightState'; -const PREFLIGHT_CACHE_MS = 6 * 60 * 60 * 1000; -const PREFLIGHT_MIN_MANDATORY_MS = 5 * 60 * 1000; -const DROPBOX_GUIDE_URL = '/cloud.html#dropbox'; -const CLOUD_STATUS_STORAGE_KEY = 'podcastStudio.cloudStatus'; -const CLOUD_STATUS_STALE_MS = 30 * 60 * 1000; -const DISK_RECORDING_STORAGE_KEY = 'podcastStudio.diskRecordingState'; -const DISK_DB_NAME = 'podcastStudio.disk'; -const DISK_DB_STORE = 'handles'; -const PODCAST_CLOUD_EVENT = 'podcast-cloud-status'; -const PODCAST_DISK_EVENT = 'podcast-disk-state'; -const PODCAST_RECORD_PLAN_EVENT = 'podcast-record-plan'; -const PODCAST_RECORD_STATUS_EVENT = 'podcast-record-status'; -const UPLOAD_TRACKER_COOLDOWN_MS = 15000; -const DRIVE_PROGRESS_EVENT = 'vdoninja:gdrive-progress'; -const DRIVE_STATUS_RESET_MS = 8000; -const DRIVE_STATUS_MESSAGES = { - idle: 'Drive idle', - pending: 'Drive readying…', - uploading: 'Drive uploading…', - done: 'Drive upload complete', - error: 'Drive upload error', -}; -const STUDIO_DISK_FEATURE_FLAG = (() => { - let enabled = true; - if (typeof urlParams !== 'undefined' && urlParams) { - const hasParam = typeof urlParams.has === 'function' ? urlParams.has('studioiso') : false; - if (hasParam) { - const rawValue = typeof urlParams.get === 'function' ? urlParams.get('studioiso') : null; - const normalized = (rawValue || '1').toString().toLowerCase(); - enabled = !['0', 'false', 'off', 'no'].includes(normalized); - } - } - return enabled; -})(); - -function injectStylesheet() { - if (document.getElementById('podcast-studio-style')) { - return; - } - const link = document.createElement('link'); - link.id = 'podcast-studio-style'; - link.rel = 'stylesheet'; - link.href = new URL('./studio.css', import.meta.url).toString(); - document.head.appendChild(link); -} - -function createElement(tag, className, attrs = {}) { - const el = document.createElement(tag); - if (className) { - el.className = className; - } - Object.entries(attrs).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (key === 'text') { - el.textContent = value; - } else { - el.setAttribute(key, value); - } - }); - return el; -} - -function dispatchStudioEvent(name, detail = {}) { - if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') { - return; - } - try { - window.dispatchEvent(new CustomEvent(name, { detail })); - } catch (error) { - console.warn('Unable to dispatch studio event', name, error); - } -} - -const SPECTROGRAM_GRADIENT = [ - { stop: 0, color: [4, 5, 13] }, // floor - { stop: 0.25, color: [24, 60, 140] }, - { stop: 0.45, color: [47, 231, 163] }, // studio green accent - { stop: 0.7, color: [255, 153, 68] }, // warning orange - { stop: 1, color: [255, 255, 255] }, -]; - -const DEFAULT_SPECTROGRAM_OPTIONS = { - fps: 24, - pixelStep: 1, - decay: 0.008, - noiseFloor: 2, - gamma: 0.65, - frequencyExponent: 0.95, - lowFrequencyCutoff: 0.55, - lowFrequencyGain: 1.1, - lowFrequencySpread: 2, -}; - -function lerpColorChannel(start, end, ratio) { - return Math.round(start + (end - start) * ratio); -} - -function pickSpectrogramColor(value) { - const clamped = Math.min(1, Math.max(0, value)); - for (let i = 1; i < SPECTROGRAM_GRADIENT.length; i += 1) { - const prev = SPECTROGRAM_GRADIENT[i - 1]; - const next = SPECTROGRAM_GRADIENT[i]; - if (clamped <= next.stop) { - const span = next.stop - prev.stop || 1; - const ratio = (clamped - prev.stop) / span; - return [ - lerpColorChannel(prev.color[0], next.color[0], ratio), - lerpColorChannel(prev.color[1], next.color[1], ratio), - lerpColorChannel(prev.color[2], next.color[2], ratio), - ]; - } - } - const fallback = SPECTROGRAM_GRADIENT[SPECTROGRAM_GRADIENT.length - 1]; - return [...fallback.color]; -} - -class SpectrogramRenderer { - constructor(canvas, options = {}) { - this.canvas = canvas; - this.ctx = canvas?.getContext ? canvas.getContext('2d', { alpha: true }) : null; - this.options = { ...DEFAULT_SPECTROGRAM_OPTIONS, ...options }; - this.pixelStepBase = Math.max(1, this.options.pixelStep); - this.frameInterval = this.options.fps > 0 ? 1000 / this.options.fps : 0; - this.lastFrame = 0; - this.animationFrame = null; - this.resizeObserver = null; - this.resizeListener = null; - this.columnBuffer = null; - this.analyser = null; - this.frequencyData = null; - this.width = 0; - this.height = 0; - this.pixelStep = this.pixelStepBase; - this.noiseFloor = Math.max(0, this.options.noiseFloor); - this.gamma = Math.max(0.25, Math.min(1.5, this.options.gamma)); - this.frequencyExponent = Math.max(0.4, Math.min(2.4, this.options.frequencyExponent)); - this.lowFrequencyCutoff = Math.min(0.9, Math.max(0.05, this.options.lowFrequencyCutoff || 0.3)); - this.lowFrequencyGain = Math.max(1, this.options.lowFrequencyGain || 1.2); - this.lowFrequencySpread = Math.max(1, Math.round(this.options.lowFrequencySpread || 2)); - this.baseFillStyle = 'rgb(4, 5, 13)'; - this.boundResize = () => this.handleResize(); - this.renderLoop = (timestamp) => this.tick(timestamp); - if (this.ctx && this.canvas) { - this.ctx.imageSmoothingEnabled = false; - this.observeResize(); - this.handleResize(); - } - } - - observeResize() { - if (!this.canvas) { - return; - } - if (typeof ResizeObserver === 'function') { - this.resizeObserver = new ResizeObserver(this.boundResize); - this.resizeObserver.observe(this.canvas); - } else { - this.resizeListener = this.boundResize; - window.addEventListener('resize', this.resizeListener); - } - } - - handleResize() { - if (!this.canvas || !this.ctx) { - return; - } - const rect = this.canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const nextWidth = Math.max(10, Math.floor(rect.width * dpr) || 10); - const nextHeight = Math.max(10, Math.floor(rect.height * dpr) || 10); - if (nextWidth === this.width && nextHeight === this.height) { - return; - } - this.width = nextWidth; - this.height = nextHeight; - this.pixelStep = Math.max(1, Math.round(this.pixelStepBase * dpr)); - this.canvas.width = nextWidth; - this.canvas.height = nextHeight; - this.columnBuffer = this.ctx.createImageData(this.pixelStep, this.height); - this.ctx.fillStyle = this.baseFillStyle; - this.ctx.fillRect(0, 0, this.width, this.height); - } - - ensureColumnBuffer() { - if (!this.ctx) { - return null; - } - if (!this.columnBuffer || this.columnBuffer.height !== this.height || this.columnBuffer.width !== this.pixelStep) { - this.columnBuffer = this.ctx.createImageData(this.pixelStep, this.height); - } - return this.columnBuffer; - } - - normalizeMagnitude(rawValue) { - if (!Number.isFinite(rawValue)) { - return 0; - } - const adjusted = Math.max(0, rawValue - this.noiseFloor); - const normalized = Math.min(1, adjusted / (255 - this.noiseFloor)); - return Math.pow(normalized, this.gamma); - } - - setAnalyser(analyser) { - if (this.analyser === analyser) { - return; - } - this.analyser = analyser || null; - this.frequencyData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : null; - if (this.analyser) { - this.startLoop(); - } else { - this.stopLoop(); - } - } - - startLoop() { - if (this.animationFrame || !this.analyser) { - return; - } - this.lastFrame = 0; - this.animationFrame = requestAnimationFrame(this.renderLoop); - } - - stopLoop() { - if (this.animationFrame) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - } - } - - tick(timestamp) { - if (!this.analyser || !this.frequencyData || !this.ctx || !this.canvas) { - this.stopLoop(); - return; - } - if (this.frameInterval && timestamp - this.lastFrame < this.frameInterval) { - this.animationFrame = requestAnimationFrame(this.renderLoop); - return; - } - this.lastFrame = timestamp; - this.drawColumn(); - this.animationFrame = requestAnimationFrame(this.renderLoop); - } - - drawColumn() { - if (!this.analyser || !this.frequencyData || !this.ctx) { - return; - } - try { - this.analyser.getByteFrequencyData(this.frequencyData); - } catch (error) { - console.warn('Spectrogram analyser unavailable', error); - this.frequencyData = null; - return; - } - const width = this.canvas.width; - const height = this.canvas.height; - const shift = Math.min(this.pixelStep, Math.max(1, width - 1)); - if (!width || !height || !shift) { - return; - } - this.ctx.drawImage(this.canvas, shift, 0, width - shift, height, 0, 0, width - shift, height); - const fadeStrength = Math.max(0, Math.min(1, this.options.decay)); - if (fadeStrength > 0 && width - shift > 0) { - this.ctx.save(); - this.ctx.globalAlpha = fadeStrength; - this.ctx.fillStyle = this.baseFillStyle; - this.ctx.fillRect(0, 0, width - shift, height); - this.ctx.restore(); - } - // clear the area reserved for the new samples - this.ctx.fillStyle = this.baseFillStyle; - this.ctx.fillRect(width - shift, 0, shift, height); - const column = this.ensureColumnBuffer(); - if (!column) { - return; - } - const bins = this.frequencyData.length; - for (let y = 0; y < height; y += 1) { - const ratio = 1 - y / height; - const curved = Math.pow(ratio, this.frequencyExponent); // slower exponent keeps low freqs visible - const baseIndex = Math.max(0, Math.min(bins - 1, Math.floor(curved * (bins - 1)))); - let accumulator = 0; - let samples = 0; - const isLowBand = curved <= this.lowFrequencyCutoff; - const spread = isLowBand ? this.lowFrequencySpread : 1; - for (let i = 0; i < spread; i += 1) { - const idx = Math.min(bins - 1, baseIndex + i); - accumulator += this.frequencyData[idx]; - samples += 1; - } - let magnitude = this.normalizeMagnitude(accumulator / Math.max(1, samples)); - if (isLowBand) { - magnitude = Math.min(1, magnitude * this.lowFrequencyGain); - } - const [r, g, b] = pickSpectrogramColor(magnitude); - const alpha = Math.round(35 + magnitude * 220); - for (let x = 0; x < shift; x += 1) { - const offset = (y * shift + x) * 4; - column.data[offset] = r; - column.data[offset + 1] = g; - column.data[offset + 2] = b; - column.data[offset + 3] = alpha; - } - } - this.ctx.putImageData(column, width - shift, 0); - } - - destroy() { - this.stopLoop(); - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - if (this.resizeListener) { - window.removeEventListener('resize', this.resizeListener); - this.resizeListener = null; - } - this.analyser = null; - this.frequencyData = null; - if (this.ctx && this.canvas) { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - } - } -} - -const ROOM_QUERY_KEYS = ['room', 'roomid', 'r']; -const DIRECTOR_QUERY_KEYS = ['director', 'dir']; -const ROOM_STATE_STORAGE_KEY = 'podcastStudio.lastRoom'; - -function sanitizeRoomSlug(value) { - if (!value) { - return ''; - } - const trimmed = String(value).trim(); - if (!trimmed) { - return ''; - } - try { - if (typeof window.sanitizeRoomName === 'function') { - return window.sanitizeRoomName(trimmed); - } - } catch (error) { - console.warn('sanitizeRoomName unavailable', error); - } - return trimmed.replace(/[^a-zA-Z0-9_\-]/g, '').slice(0, 64); -} - -function getRoomSlugFromParams(params = new URLSearchParams(window.location.search)) { - for (const key of DIRECTOR_QUERY_KEYS) { - if (params.has(key)) { - const slug = sanitizeRoomSlug(params.get(key)); - if (slug) { - return slug; - } - } - } - for (const key of ROOM_QUERY_KEYS) { - if (params.has(key)) { - const slug = sanitizeRoomSlug(params.get(key)); - if (slug) { - return slug; - } - } - } - return ''; -} - -function readStoredRoomState() { - try { - const raw = window.localStorage.getItem(ROOM_STATE_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return { - room: typeof parsed.room === 'string' ? parsed.room : '', - password: typeof parsed.password === 'string' ? parsed.password : '', - }; - } - } catch (error) { - console.warn('Unable to read stored room state', error); - } - return {}; -} - -function persistStoredRoomState(state) { - try { - window.localStorage.setItem(ROOM_STATE_STORAGE_KEY, JSON.stringify(state || {})); - } catch (error) { - console.warn('Unable to store room state', error); - } -} - -function readPreflightState() { - try { - const raw = window.localStorage.getItem(PREFLIGHT_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch (error) { - console.warn('Unable to read preflight cache', error); - } - return {}; -} - -function writePreflightState(state) { - try { - window.localStorage.setItem(PREFLIGHT_STORAGE_KEY, JSON.stringify(state || {})); - } catch (error) { - console.warn('Unable to persist preflight cache', error); - } -} - -function isPreflightFresh(timestamp) { - if (!timestamp) { - return false; - } - return Date.now() - timestamp < PREFLIGHT_CACHE_MS; -} - -function formatRelativeTime(timestamp) { - if (!timestamp) { - return ''; - } - const deltaSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); - if (deltaSeconds < 45) { - return 'just now'; - } - if (deltaSeconds < 90) { - return 'about a minute ago'; - } - if (deltaSeconds < 45 * 60) { - const minutes = Math.round(deltaSeconds / 60); - return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; - } - if (deltaSeconds < 90 * 60) { - return 'about an hour ago'; - } - if (deltaSeconds < 36 * 3600) { - const hours = Math.round(deltaSeconds / 3600); - return `${hours} hour${hours === 1 ? '' : 's'} ago`; - } - const days = Math.round(deltaSeconds / 86400); - return `${days} day${days === 1 ? '' : 's'} ago`; -} - -function createRecordingSessionId() { - try { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return crypto.randomUUID(); - } - } catch (error) { - console.warn('randomUUID unavailable', error); - } - return `rec-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -function snapshotHighResClock() { - if (typeof performance === 'undefined' || typeof performance.now !== 'function') { - return null; - } - const now = performance.now(); - const origin = - typeof performance.timeOrigin === 'number' - ? performance.timeOrigin - : Date.now() - now; - return { - perfNow: now, - timeOrigin: origin, - wallClockMs: Math.round(origin + now), - }; -} - -function readCloudLinkStatus() { - try { - const raw = window.localStorage.getItem(CLOUD_STATUS_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (error) { - console.warn('Unable to read cloud link status', error); - return {}; - } -} - -function writeCloudLinkStatus(nextState) { - const snapshot = nextState || {}; - try { - window.localStorage.setItem(CLOUD_STATUS_STORAGE_KEY, JSON.stringify(snapshot)); - } catch (error) { - console.warn('Unable to persist cloud link status', error); - return; - } - dispatchStudioEvent(PODCAST_CLOUD_EVENT, { state: snapshot }); -} - -function isCloudLinkFresh(entry) { - if (!entry?.linkedAt) { - return false; - } - return Date.now() - entry.linkedAt < CLOUD_STATUS_STALE_MS; -} - -function markCloudLinked(service, details = {}) { - if (!service) { - return; - } - const state = readCloudLinkStatus(); - state[service] = { - linkedAt: Date.now(), - ...details, - }; - writeCloudLinkStatus(state); -} - -function markCloudUnlinked(service) { - if (!service) { - return; - } - const state = readCloudLinkStatus(); - if (state[service]) { - delete state[service]; - writeCloudLinkStatus(state); - } -} - -function readDiskRecordingState() { - try { - const raw = window.localStorage.getItem(DISK_RECORDING_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (error) { - console.warn('Unable to read disk recording state', error); - return {}; - } -} - -function isDiskRecordingEnabled() { - const state = readDiskRecordingState(); - return Boolean(state.folderName && state.enabled); -} - -function setDiskRecordingEnabled(enabled) { - const current = readDiskRecordingState(); - const next = { - ...current, - enabled: Boolean(enabled) && Boolean(current.folderName), - updatedAt: Date.now(), - }; - writeDiskRecordingState(next); - return next; -} - -function writeDiskRecordingState(state) { - const snapshot = state || {}; - try { - window.localStorage.setItem(DISK_RECORDING_STORAGE_KEY, JSON.stringify(snapshot)); - } catch (error) { - console.warn('Unable to persist disk recording state', error); - return; - } - dispatchStudioEvent(PODCAST_DISK_EVENT, { state: snapshot }); -} - -function openDiskHandleDatabase() { - return new Promise((resolve, reject) => { - if (!window.indexedDB) { - reject(new Error('IndexedDB unavailable')); - return; - } - const request = window.indexedDB.open(DISK_DB_NAME, 1); - request.onerror = () => reject(request.error || new Error('Unable to open disk handle database')); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(DISK_DB_STORE)) { - db.createObjectStore(DISK_DB_STORE); - } - }; - request.onsuccess = () => resolve(request.result); - }); -} - -async function saveDiskDirectoryHandle(handle) { - if (!handle) { - return; - } - const db = await openDiskHandleDatabase(); - await new Promise((resolve, reject) => { - const tx = db.transaction(DISK_DB_STORE, 'readwrite'); - tx.oncomplete = () => { - db.close(); - resolve(); - }; - tx.onerror = () => { - db.close(); - reject(tx.error || new Error('Unable to store disk handle')); - }; - tx.objectStore(DISK_DB_STORE).put(handle, 'primary'); - }); -} - -async function readDiskDirectoryHandle() { - const db = await openDiskHandleDatabase(); - return new Promise((resolve, reject) => { - const tx = db.transaction(DISK_DB_STORE, 'readonly'); - tx.oncomplete = () => { - db.close(); - }; - tx.onerror = () => { - db.close(); - reject(tx.error || new Error('Unable to read disk handle')); - }; - const request = tx.objectStore(DISK_DB_STORE).get('primary'); - request.onsuccess = () => resolve(request.result || null); - }); -} - -async function verifyStoredDiskRecordingDirectory({ requestPermission = false } = {}) { - try { - const handle = await readDiskDirectoryHandle(); - if (!handle) { - return { ok: false, message: 'No folder selected yet.' }; - } - let permission = await handle.queryPermission({ mode: 'readwrite' }); - if (permission === 'prompt' && requestPermission) { - permission = await handle.requestPermission({ mode: 'readwrite' }); - } - if (permission !== 'granted') { - return { ok: false, message: 'Access to the selected folder was denied.' }; - } - const meta = readDiskRecordingState(); - writeDiskRecordingState({ - ...meta, - lastVerifiedAt: Date.now(), - folderName: meta.folderName || handle.name || 'Selected folder', - lastError: null, - }); - return { ok: true, folderName: meta.folderName || handle.name || 'Selected folder' }; - } catch (error) { - console.warn('Failed to verify disk folder', error); - const meta = readDiskRecordingState(); - writeDiskRecordingState({ - ...meta, - lastError: error?.message || 'Unable to verify folder access.', - }); - return { ok: false, message: error?.message || 'Unable to verify folder access.' }; - } -} - -async function chooseDiskRecordingDirectory() { - if (typeof window.showDirectoryPicker !== 'function') { - throw new Error('This browser does not support the file-system directory picker yet.'); - } - const handle = await window.showDirectoryPicker({ mode: 'readwrite' }); - if (!handle) { - throw new Error('Folder selection was cancelled.'); - } - await saveDiskDirectoryHandle(handle); - const meta = readDiskRecordingState(); - writeDiskRecordingState({ - ...meta, - folderName: handle.name || 'Recording folder', - lastVerifiedAt: Date.now(), - lastError: null, - }); - return { handle, folderName: handle.name || 'Recording folder' }; -} - -function buildRoomGate(defaults = {}) { - injectStylesheet(); - document.body.classList.remove('hidden'); - document.body.classList.add('podcast-studio-mode'); - - const gate = createElement('div', '', { id: 'podcast-room-gate' }); - const panel = createElement('div', 'podcast-room-gate__panel'); - const title = createElement('h1', 'podcast-room-gate__title', { text: 'Start a Control Room' }); - const subtitle = createElement('p', 'podcast-room-gate__subtitle', { - text: 'Name your room to invite talent and capture their tracks. This matches the “&director=” link you share with guests.', - }); - - const form = createElement('form', 'podcast-room-gate__form'); - const roomLabel = createElement('label'); - roomLabel.append(createElement('span', '', { text: 'Room name' })); - const roomInput = createElement('input'); - roomInput.name = 'room'; - roomInput.placeholder = defaults.roomPlaceholder || 'podcast-hq'; - roomInput.autocomplete = 'off'; - roomInput.autocapitalize = 'off'; - roomInput.spellcheck = false; - if (defaults.room) { - roomInput.value = defaults.room; - } - roomLabel.append(roomInput); - - const passwordLabel = createElement('label'); - passwordLabel.append(createElement('span', '', { text: 'Room password (optional)' })); - const passwordInput = createElement('input'); - passwordInput.name = 'password'; - passwordInput.placeholder = 'Leave blank to skip'; - passwordInput.type = 'text'; - passwordInput.autocomplete = 'off'; - passwordInput.autocapitalize = 'off'; - passwordInput.spellcheck = false; - if (defaults.password) { - passwordInput.value = defaults.password; - } - passwordLabel.append(passwordInput); - - const errorNode = createElement('div', 'podcast-room-gate__error'); - - const actions = createElement('div', 'podcast-room-gate__actions'); - const cancelButton = createElement('button', 'podcast-room-gate__cancel', { type: 'button', text: 'Back to classic' }); - const submitButton = createElement('button', 'podcast-room-gate__submit', { type: 'submit', text: 'Enter studio' }); - actions.append(submitButton, cancelButton); - - form.append(roomLabel, passwordLabel, errorNode, actions); - panel.append(title, subtitle, form); - gate.append(panel); - document.body.append(gate); - - setTimeout(() => { - roomInput.focus(); - roomInput.select(); - }, 0); - - return { - gate, - form, - roomInput, - passwordInput, - errorNode, - submitButton, - cancelButton, - }; -} - -async function ensureRoomSelection() { - const params = new URLSearchParams(window.location.search); - const existing = getRoomSlugFromParams(params); - if (existing) { - injectStylesheet(); - document.body.classList.remove('hidden'); - document.body.classList.add('podcast-studio-mode'); - const preflight = await runPreflightChecklist({ roomSlug: existing }); - if (preflight?.redirect) { - return preflight; - } - return { roomSlug: preflight?.roomSlug || existing }; - } - - const stored = readStoredRoomState(); - const gateElements = buildRoomGate(stored); - - return new Promise((resolve) => { - function redirectToClassic() { - const base = window.location.pathname; - gateElements.cancelButton.disabled = true; - gateElements.submitButton.disabled = true; - window.location.href = base || '/'; - resolve({ redirect: true }); - } - - function handleSubmit(event) { - event.preventDefault(); - const slug = sanitizeRoomSlug(gateElements.roomInput.value); - if (!slug) { - gateElements.errorNode.textContent = 'Room name is required.'; - return; - } - gateElements.errorNode.textContent = ''; - gateElements.submitButton.disabled = true; - gateElements.cancelButton.disabled = true; - - const updatedParams = new URLSearchParams(window.location.search); - updatedParams.set('studio', 'podcast'); - updatedParams.set('director', slug); - for (const key of DIRECTOR_QUERY_KEYS) { - if (key !== 'director') { - updatedParams.delete(key); - } - } - for (const key of ROOM_QUERY_KEYS) { - updatedParams.delete(key); - } - - const password = gateElements.passwordInput.value.trim(); - if (password) { - updatedParams.set('password', password); - } else { - updatedParams.delete('password'); - } - - persistStoredRoomState({ room: slug, password }); - window.location.search = updatedParams.toString(); - resolve({ redirect: true }); - } - - gateElements.form.addEventListener('submit', (event) => handleSubmit(event)); - gateElements.cancelButton.addEventListener('click', (event) => { - event.preventDefault(); - redirectToClassic(); - }); - // Rely on form submit for enter/return handling. - }); -} - -function describePreflightStatus(status) { - switch (status) { - case 'ready': - return 'Ready'; - case 'testing': - return 'Testing…'; - case 'error': - return 'Needs attention'; - default: - return 'Pending'; - } -} - -function createPreflightRow(label, description, options = {}) { - const { - initialStatus = 'pending', - actionLabel = 'Test', - showAction = true, - } = options; - const row = createElement('div', 'preflight-row'); - row.dataset.status = initialStatus; - - const info = createElement('div', 'preflight-row__info'); - const labelNode = createElement('div', 'preflight-row__label', { text: label }); - const descriptionNode = createElement('div', 'preflight-row__description', { text: description }); - const messageNode = createElement('div', 'preflight-row__message'); - info.append(labelNode, descriptionNode, messageNode); - - const controls = createElement('div', 'preflight-row__controls'); - const statusNode = createElement('span', 'preflight-row__status', { text: describePreflightStatus(initialStatus) }); - controls.append(statusNode); - - let actionButton = null; - if (showAction) { - actionButton = createElement('button', 'preflight-row__action', { type: 'button', text: actionLabel }); - controls.append(actionButton); - } - - row.append(info, controls); - return { - row, - info, - statusNode, - messageNode, - actionButton, - }; -} - -function setPreflightRowState(rowParts, status, message = '') { - if (!rowParts || !rowParts.row) { - return; - } - rowParts.row.dataset.status = status; - if (rowParts.statusNode) { - rowParts.statusNode.textContent = describePreflightStatus(status); - } - if (rowParts.messageNode) { - rowParts.messageNode.textContent = message || ''; - } - if (rowParts.actionButton) { - if (status === 'testing') { - rowParts.actionButton.disabled = true; - } else { - rowParts.actionButton.disabled = false; - } - if (status === 'ready') { - rowParts.actionButton.textContent = 'Retest'; - } else if (status === 'testing') { - rowParts.actionButton.textContent = 'Testing…'; - } else if (status === 'error') { - rowParts.actionButton.textContent = 'Retry'; - } else { - rowParts.actionButton.textContent = rowParts.actionButton.dataset.initialLabel || 'Test'; - } - } -} - -async function runPreflightChecklist({ roomSlug } = {}) { - const stored = readPreflightState(); - const now = Date.now(); - const micFresh = isPreflightFresh(stored.micSuccessAt); - const camFresh = isPreflightFresh(stored.cameraSuccessAt); - - // If the user just completed the preflight moments ago, allow immediate pass-through. - if (stored.completedAt && now - stored.completedAt < PREFLIGHT_MIN_MANDATORY_MS) { - return { roomSlug, skipped: true }; - } - - const overlay = createElement('div', 'podcast-preflight-backdrop'); - overlay.dataset.podcastOverlay = 'true'; - const panel = createElement('div', 'podcast-preflight-panel'); - panel.setAttribute('role', 'dialog'); - panel.setAttribute('aria-modal', 'true'); - panel.setAttribute('aria-label', 'Podcast studio preflight checklist'); - - const heading = createElement('h2', 'preflight-title', { text: 'Check Your Setup' }); - const subtitleText = roomSlug - ? `Confirm your gear before directing room “${roomSlug}”.` - : 'Confirm your gear before directing a session.'; - const subtitle = createElement('p', 'preflight-subtitle', { text: subtitleText }); - - const checklist = createElement('div', 'preflight-list'); - const micRow = createPreflightRow('Microphone access', 'Verify your preferred mic is available and browser permission is granted.', { - initialStatus: micFresh ? 'ready' : 'pending', - actionLabel: micFresh ? 'Retest' : 'Test mic', - }); - if (micRow.actionButton) { - micRow.actionButton.dataset.initialLabel = micFresh ? 'Retest' : 'Test mic'; - } - if (micFresh) { - setPreflightRowState(micRow, 'ready', `Last checked ${formatRelativeTime(stored.micSuccessAt)}.`); - } - - const camRow = createPreflightRow('Camera access', 'Optional but useful if you plan to capture video.', { - initialStatus: camFresh ? 'ready' : 'pending', - actionLabel: camFresh ? 'Retest' : 'Test camera', - }); - if (camRow.actionButton) { - camRow.actionButton.dataset.initialLabel = camFresh ? 'Retest' : 'Test camera'; - } - if (camFresh) { - setPreflightRowState(camRow, 'ready', `Last checked ${formatRelativeTime(stored.cameraSuccessAt)}.`); - } - - const cloudRow = createPreflightRow('Cloud sync', 'Link Google Drive or Dropbox before recording so uploads can start immediately.', { - initialStatus: 'pending', - showAction: true, - actionLabel: 'Check status', - }); - if (cloudRow.actionButton) { - cloudRow.actionButton.dataset.initialLabel = 'Check status'; - } - setPreflightRowState(cloudRow, 'pending', 'Checking saved tokens…'); - - const diskRow = createPreflightRow( - 'Local disk recording', - 'Select a destination folder for ISO files (optional but recommended).', - { - initialStatus: 'pending', - showAction: Boolean(window.showDirectoryPicker), - actionLabel: window.showDirectoryPicker ? 'Choose folder' : 'Unavailable', - } - ); - if (!window.showDirectoryPicker && diskRow.actionButton) { - diskRow.actionButton.disabled = true; - } - setPreflightRowState( - diskRow, - window.showDirectoryPicker ? 'pending' : 'error', - window.showDirectoryPicker ? 'No folder selected yet.' : 'Local disk recording requires the File System Access API (Chromium-based browsers).' - ); - - checklist.append(micRow.row, camRow.row, cloudRow.row, diskRow.row); - - const actions = createElement('div', 'preflight-actions'); - const continueButton = createElement('button', 'preflight-primary', { type: 'button', text: 'Enter Control Room' }); - const skipButton = createElement('button', 'preflight-secondary', { type: 'button', text: 'Skip preflight' }); - actions.append(continueButton, skipButton); - - panel.append(heading, subtitle, checklist, actions); - overlay.append(panel); - document.body.append(overlay); - - let micOk = Boolean(micFresh); - let camOk = Boolean(camFresh); - let cloudTimer = null; - let cloudOk = false; - let diskReady = false; - let destroyed = false; - let cloudStatusListener = null; - let diskStatusListener = null; - let cloudLastChecked = null; - let resolver; - const completion = new Promise((resolve) => { - resolver = resolve; - }); - - function closeOverlay(result = {}) { - if (destroyed) { - return; - } - destroyed = true; - if (cloudTimer) { - clearInterval(cloudTimer); - cloudTimer = null; - } - if (cloudStatusListener) { - window.removeEventListener(PODCAST_CLOUD_EVENT, cloudStatusListener); - cloudStatusListener = null; - } - if (diskStatusListener) { - window.removeEventListener(PODCAST_DISK_EVENT, diskStatusListener); - diskStatusListener = null; - } - if (overlay && overlay.parentNode) { - overlay.parentNode.removeChild(overlay); - } - const payload = { roomSlug, ...result }; - if (result.completed) { - writePreflightState({ - ...stored, - completedAt: Date.now(), - micSuccessAt: micOk ? (stored.micSuccessAt || Date.now()) : stored.micSuccessAt, - cameraSuccessAt: camOk ? (stored.cameraSuccessAt || Date.now()) : stored.cameraSuccessAt, - roomSlug, - }); - } else { - writePreflightState({ - ...stored, - micSuccessAt: micOk ? (stored.micSuccessAt || Date.now()) : stored.micSuccessAt, - cameraSuccessAt: camOk ? (stored.cameraSuccessAt || Date.now()) : stored.cameraSuccessAt, - roomSlug, - }); - } - if (typeof resolver === 'function') { - resolver(payload); - resolver = null; - } - } - - function updateContinueState() { - continueButton.disabled = !micOk; - continueButton.title = micOk ? '' : 'Run the microphone test to continue.'; - } - - updateContinueState(); - - async function runMediaTest(kind) { - if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') { - throw new Error('Browser does not support media tests.'); - } - const constraints = kind === 'video' ? { video: true } : { audio: { echoCancellation: false } }; - const stream = await navigator.mediaDevices.getUserMedia(constraints); - stream.getTracks().forEach((track) => { - try { - track.stop(); - } catch (error) { - console.warn('Unable to stop track', error); - } - }); - } - - if (micRow.actionButton) { - micRow.actionButton.addEventListener('click', async () => { - setPreflightRowState(micRow, 'testing', 'Requesting microphone access…'); - try { - await runMediaTest('audio'); - micOk = true; - const timestamp = Date.now(); - stored.micSuccessAt = timestamp; - setPreflightRowState(micRow, 'ready', 'Microphone ready to record.'); - } catch (error) { - console.error('Microphone check failed', error); - micOk = false; - setPreflightRowState( - micRow, - 'error', - error?.message ? error.message : 'Unable to access microphone.' - ); - } - updateContinueState(); - writePreflightState({ ...stored, micSuccessAt: micOk ? Date.now() : stored.micSuccessAt, roomSlug }); - }); - } - - if (camRow.actionButton) { - camRow.actionButton.addEventListener('click', async () => { - setPreflightRowState(camRow, 'testing', 'Requesting camera access…'); - try { - await runMediaTest('video'); - camOk = true; - const timestamp = Date.now(); - stored.cameraSuccessAt = timestamp; - setPreflightRowState(camRow, 'ready', 'Camera detected.'); - writePreflightState({ ...stored, cameraSuccessAt: timestamp, roomSlug }); - } catch (error) { - console.error('Camera check failed', error); - camOk = false; - setPreflightRowState( - camRow, - 'error', - error?.message ? error.message : 'Unable to access camera.' - ); - writePreflightState({ ...stored, roomSlug }); - } - }); - } - - async function refreshCloudRowStatus({ manual = false } = {}) { - cloudLastChecked = Date.now(); - const state = readCloudLinkStatus(); - const driveFresh = isCloudLinkFresh(state.drive); - const dropboxFresh = isCloudLinkFresh(state.dropbox); - const summaries = []; - summaries.push( - driveFresh - ? `Drive linked ${formatRelativeTime(state.drive.linkedAt) || 'recently'}` - : 'Drive not linked', - ); - summaries.push( - dropboxFresh - ? `Dropbox linked ${formatRelativeTime(state.dropbox.linkedAt) || 'recently'}` - : 'Dropbox not linked', - ); - cloudOk = driveFresh || dropboxFresh; - const summaryText = summaries.join(' • '); - const checkedSuffix = cloudLastChecked ? `Checked ${formatRelativeTime(cloudLastChecked)}.` : ''; - if (cloudOk) { - setPreflightRowState( - cloudRow, - 'ready', - `${summaryText}.${checkedSuffix ? ` ${checkedSuffix}` : ''}`, - ); - } else { - setPreflightRowState( - cloudRow, - 'error', - `${summaryText}. Link your cloud targets from inside the control room.${ - checkedSuffix ? ` ${checkedSuffix}` : '' - }`, - ); - } - } - - async function refreshDiskRowStatus({ interactive = false } = {}) { - if (typeof window.showDirectoryPicker !== 'function') { - diskReady = false; - setPreflightRowState( - diskRow, - 'error', - 'Local disk recording requires a Chromium-based browser with the File System Access API.' - ); - if (diskRow.actionButton) { - diskRow.actionButton.disabled = true; - } - return; - } - const diskState = readDiskRecordingState(); - if (!diskState.folderName) { - diskReady = false; - if (diskRow.actionButton) { - diskRow.actionButton.textContent = 'Choose folder'; - diskRow.actionButton.disabled = false; - } - setPreflightRowState(diskRow, 'pending', 'Pick a folder to enable local ISO recording.'); - return; - } - setPreflightRowState(diskRow, 'testing', 'Validating folder permissions…'); - const result = await verifyStoredDiskRecordingDirectory({ requestPermission: interactive }); - if (result.ok) { - diskReady = true; - const meta = readDiskRecordingState(); - if (diskRow.actionButton) { - diskRow.actionButton.textContent = 'Change folder'; - diskRow.actionButton.disabled = false; - } - const checked = meta.lastVerifiedAt ? `Last checked ${formatRelativeTime(meta.lastVerifiedAt)}.` : 'Ready to write.'; - setPreflightRowState(diskRow, 'ready', `Folder: ${result.folderName}. ${checked}`); - } else { - diskReady = false; - if (diskRow.actionButton) { - diskRow.actionButton.textContent = 'Choose folder'; - diskRow.actionButton.disabled = false; - } - setPreflightRowState(diskRow, 'error', result.message || 'Unable to access the selected folder.'); - } - } - - refreshCloudRowStatus(); - refreshDiskRowStatus(); - cloudTimer = setInterval(() => { - refreshCloudRowStatus({ manual: false }); - refreshDiskRowStatus(); - }, 8000); - cloudStatusListener = () => refreshCloudRowStatus(); - diskStatusListener = () => refreshDiskRowStatus(); - window.addEventListener(PODCAST_CLOUD_EVENT, cloudStatusListener); - window.addEventListener(PODCAST_DISK_EVENT, diskStatusListener); - - if (cloudRow.actionButton) { - cloudRow.actionButton.addEventListener('click', () => { - setPreflightRowState(cloudRow, 'testing', 'Re-checking your saved cloud tokens…'); - refreshCloudRowStatus({ manual: true }); - }); - } - - if (diskRow.actionButton) { - diskRow.actionButton.addEventListener('click', async () => { - if (diskRow.actionButton.disabled) { - return; - } - try { - setPreflightRowState(diskRow, 'testing', 'Waiting for folder selection…'); - await chooseDiskRecordingDirectory(); - await refreshDiskRowStatus({ interactive: true }); - } catch (error) { - diskReady = false; - const message = - error?.name === 'AbortError' || /cancel/i.test(error?.message || '') - ? 'Folder selection cancelled.' - : error?.message || 'Unable to choose folder.'; - setPreflightRowState(diskRow, 'error', message); - } - }); - } - - continueButton.addEventListener('click', () => { - if (!micOk) { - setPreflightRowState(micRow, 'error', 'Microphone test is required before entering.'); - return; - } - closeOverlay({ completed: true }); - }); - - skipButton.addEventListener('click', () => { - closeOverlay({ skipped: true }); - }); - - overlay.addEventListener('click', (event) => { - if (event.target === overlay) { - closeOverlay({ skipped: true }); - } - }); - - document.addEventListener( - 'keydown', - (event) => { - if (destroyed) { - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - closeOverlay({ skipped: true }); - } - }, - { once: true } - ); - - return completion; -} - -function extractPeerAudioStats(peer) { - const stats = peer?.stats || {}; - const candidates = [ - stats.audio_bitrate_kbps, - stats.inbound_audio_bitrate_kbps, - stats.total_audio_bitrate_kbps, - stats.total_sending_bitrate_kbps, - ]; - const audioBitrateKbps = candidates.find((value) => typeof value === 'number' && value >= 0); - const codec = - typeof stats.audio_codec === 'string' - ? stats.audio_codec - : typeof stats.audio_codec_in === 'string' - ? stats.audio_codec_in - : typeof stats.audio_codec_out === 'string' - ? stats.audio_codec_out - : ''; - return { - audioBitrateKbps: audioBitrateKbps ?? null, - audioCodec: codec || null, - }; -} - -function collectParticipants(session) { - const participants = []; - - Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => { - if (!peer) { - return; - } - const audioTracks = peer.streamSrc?.getAudioTracks?.() || []; - let level = 0; - if (peer.stats && typeof peer.stats.Audio_Loudness === 'number') { - level = peer.stats.Audio_Loudness; - } else if (peer.audioMeter) { - level = peer.audioMeter.level || 0; - } - const { audioBitrateKbps, audioCodec } = extractPeerAudioStats(peer); - participants.push({ - uuid, - label: peer.label || peer.streamID || `Guest ${uuid.substring(0, 4)}`, - streamID: peer.streamID, - status: (peer.streamSrc && audioTracks.length) ? 'connected' : 'connecting', - audioLevel: level, - isLocal: false, - role: 'remote', - audioBitrateKbps, - audioCodec, - }); - }); - - return participants; -} - -class PodcastStudioApp { - constructor(options = {}) { - this.options = options || {}; - this.roomHint = this.options.roomHint || ''; - this.session = null; - this.cloud = null; - this.recorder = null; - this.audioContext = null; - this.recording = false; - this.rosterItems = new Map(); - this.rosterDriveButtons = new Map(); - this.rosterDriveStatuses = new Map(); - this.driveStatusResetTimers = new Map(); - this.driveProgressSnapshots = new Map(); - this.meterValues = new Map(); - this.outputIndicators = new Map(); - this.trackRuntimeStats = new Map(); - this.trackLevelNodes = new Map(); - this.spectrograms = new Map(); - this.participantMetrics = new Map(); - this.markers = []; - this.rosterTimer = null; - this.pendingDriveUploads = []; - this.levelOff = null; - this.recordStartedAt = null; - this.driveStatusNode = null; - this.dropboxStatusNode = null; - this.abortUploadsController = null; - this.activeDownloadUrls = []; - this.stopMeterBridge = null; - this.roomName = this.roomHint || ''; - this.virtualParticipants = new Map(); - this.hostMic = null; - this.hostMicMeter = null; - this.hostMicButton = null; - this.hostMicStatusNode = null; - this.hostMicErrorNode = null; - this.hostMicBusy = false; - this.cloudBusy = { - drive: false, - dropbox: false, - }; - this.cloudLinkButtons = { - drive: null, - dropbox: null, - }; - this.cloudLinkStatusNodes = { - drive: null, - dropbox: null, - }; - this.cloudLinkMessages = { - drive: null, - dropbox: null, - }; - this.cloudLinkMessageTextNodes = { - drive: null, - dropbox: null, - }; - this.dropboxTokenInput = null; - this.dropboxTokenRow = null; - this.dropboxGuideRow = null; - this.inviteLinkInput = null; - this.inviteCopyButton = null; - this.inviteStatusNode = null; - this.inviteOptionNodes = {}; - this.inviteCopyTimer = null; - this.remoteOverlay = null; - this.remoteOverlayContent = null; - this.remoteControlState = { - activeUuid: null, - element: null, - placeholder: null, - wrapper: null, - }; - this.cloudProgressNodes = { - drive: null, - dropbox: null, - }; - this.uploadTrackers = { - drive: new Map(), - dropbox: new Map(), - }; - this.chatModule = null; - this.chatPlaceholder = null; - this.chatPanel = null; - this.chatCollapseButton = null; - this.chatPopoutButton = null; - this.chatCollapsed = false; - this.chatPopoutAnchor = null; - this.chatCollapsedHint = null; - this.diskControls = null; - this.diskToggleButton = null; - this.diskFolderButton = null; - this.diskStatusNode = null; - this.diskRecordingEnabled = isDiskRecordingEnabled(); - this.diskStateListener = null; - this.cloudStateListener = null; - this.cloudSummaryNode = null; - this.diskSummaryNode = null; - this.recordingStatusNode = null; - this.recordingPlan = null; - this.recordingSessionId = null; - this.boundDriveProgressHandler = null; - } - - async init() { - document.body.classList.remove('hidden'); - document.body.classList.add('podcast-studio-mode'); - injectStylesheet(); - - this.session = await waitForLegacySession({ timeoutMs: 15000 }); - this.applyDirectorAudioDefaults(); - this.audioContext = this.session.audioCtx || this.session.audioCtxOutbound || this.createAudioContext(); - this.recorder = new MultiTrackRecorder({ - audioContext: this.audioContext, - includeVideo: false, - includeScreenshares: false, - monitorLevels: true, - timeslice: 1000, - }); - this.cloud = new CloudUploadCoordinator(this.session); - - this.roomName = this.resolveRoomName(); - this.buildLayout(); - if (STUDIO_DISK_FEATURE_FLAG) { - this.diskStateListener = () => { - this.updateDiskRecordingUI(); - this.updateReadinessSummary(); - }; - window.addEventListener(PODCAST_DISK_EVENT, this.diskStateListener); - } - this.cloudStateListener = () => this.updateReadinessSummary(); - window.addEventListener(PODCAST_CLOUD_EVENT, this.cloudStateListener); - this.boundDriveProgressHandler = (event) => this.handleDriveProgressEvent(event); - window.addEventListener(DRIVE_PROGRESS_EVENT, this.boundDriveProgressHandler); - this.updateRoomIndicator(); - this.updateCloudFooter(); - this.attachRecorderEvents(); - this.refreshRoster(); - this.startRosterLoop(); - this.levelOff = levelBus.on(LEVEL_EVENT, (payload) => this.updateMeterFromBus(payload)); - try { - this.stopMeterBridge = await bridgeLegacyMeters(); - } catch (error) { - console.warn('Failed to bridge legacy meter events', error); - } - } - - resolveRoomName() { - if (this.session?.roomid && this.session.roomid !== true) { - return sanitizeRoomSlug(this.session.roomid); - } - if (this.session?.director && this.session.director !== true) { - return sanitizeRoomSlug(this.session.director); - } - if (this.roomHint) { - return sanitizeRoomSlug(this.roomHint); - } - const paramsSlug = getRoomSlugFromParams(); - if (paramsSlug) { - return paramsSlug; - } - return ''; - } - - updateRoomIndicator() { - const latest = this.resolveRoomName(); - if (latest !== this.roomName) { - this.roomName = latest; - if (this.sessionInfo) { - this.sessionInfo.textContent = 'Room: ' + (this.roomName || '—'); - } - if (this.roomName) { - const stored = readStoredRoomState(); - persistStoredRoomState({ room: this.roomName, password: stored?.password || '' }); - } - } else if (!this.roomName && this.sessionInfo) { - this.sessionInfo.textContent = 'Room: —'; - } - this.updateInviteLink(); - } - - describeInviteOptions() { - const labels = []; - if (this.inviteOptionNodes.proAudio?.checked) { - labels.push('Pro audio'); - } - if (this.inviteOptionNodes.disableAec?.checked) { - labels.push('AEC off'); - } - if (this.inviteOptionNodes.disableDenoise?.checked) { - labels.push('Denoise off'); - } - if (this.inviteOptionNodes.disableAgc?.checked) { - labels.push('AGC off'); - } - return labels.join(' • '); - } - - applyDirectorAudioDefaults() { - if (!this.session) { - return; - } - if (this.session.stereo === undefined || this.session.stereo === null || this.session.stereo === false || this.session.stereo === 0) { - this.session.stereo = 1; - } - if (!this.session.audiobitrate || this.session.audiobitrate < 192) { - this.session.audiobitrate = 256; - } - if (!this.session.outboundAudioBitrate || this.session.outboundAudioBitrate < 192) { - this.session.outboundAudioBitrate = 256; - } - if (typeof this.session.autoGainControl === 'undefined') { - this.session.autoGainControl = false; - } - if (typeof this.session.noiseSuppression === 'undefined') { - this.session.noiseSuppression = false; - } - if (typeof this.session.echoCancellation === 'undefined') { - this.session.echoCancellation = false; - } - if (typeof this.session.applyStereoDefaults === 'function') { - try { - this.session.applyStereoDefaults(); - } catch (error) { - console.warn('applyStereoDefaults failed', error); - } - } - } - - updateInviteLink() { - if (!this.inviteLinkInput) { - return; - } - const room = this.resolveRoomName(); - if (!room) { - this.inviteLinkInput.value = 'Set a room name to generate a guest link'; - this.inviteLinkInput.dataset.state = 'placeholder'; - if (this.inviteCopyButton) { - this.inviteCopyButton.disabled = true; - } - if (this.inviteStatusNode) { - this.inviteStatusNode.textContent = ''; - } - return; - } - this.inviteLinkInput.dataset.state = 'ready'; - if (this.inviteCopyButton) { - this.inviteCopyButton.disabled = false; - } - const guestUrl = new URL(window.location.href); - guestUrl.search = ''; - guestUrl.hash = ''; - - const params = new URLSearchParams(); - params.set('room', room); - params.set('miconly', '1'); - params.set('style', '2'); - params.set('showlabel', '1'); - params.set('tips', '1'); - params.set('label', ''); - - const options = this.inviteOptionNodes || {}; - const summary = []; - summary.push('Mic only'); - summary.push('Label prompt'); - summary.push('Name tag overlay'); - summary.push('Join tips'); - - if (options.proAudio?.checked) { - params.set('proaudio', '1'); - params.set('stereo', '1'); - params.set('audiobitrate', '256'); - summary.push('Pro audio'); - } else { - params.delete('proaudio'); - params.delete('stereo'); - params.delete('audiobitrate'); - } - - if (options.disableAec?.checked) { - params.set('aec', '0'); - params.set('echocancellation', '0'); - summary.push('AEC off'); - } else { - params.delete('aec'); - params.delete('echocancellation'); - } - - if (options.disableDenoise?.checked) { - params.set('denoise', '0'); - summary.push('Denoise off'); - } else { - params.delete('denoise'); - } - - if (options.disableAgc?.checked) { - params.set('agc', '0'); - params.set('autogain', '0'); - summary.push('AGC off'); - } else { - params.delete('agc'); - params.delete('autogain'); - } - - guestUrl.search = params.toString(); - const value = guestUrl.toString(); - this.inviteLinkInput.value = value; - if (this.inviteStatusNode) { - this.inviteStatusNode.textContent = summary.length ? summary.join(' • ') : 'Default settings'; - } - } - - async copyInviteLink() { - if (!this.inviteLinkInput || this.inviteLinkInput.dataset.state === 'placeholder') { - return; - } - const value = this.inviteLinkInput.value; - if (!value) { - return; - } - const notify = (message, variant = 'info') => { - if (!this.inviteStatusNode) { - return; - } - this.inviteStatusNode.textContent = message; - this.inviteStatusNode.dataset.variant = variant; - if (this.inviteCopyTimer) { - clearTimeout(this.inviteCopyTimer); - } - this.inviteCopyTimer = setTimeout(() => { - this.inviteStatusNode.dataset.variant = ''; - this.updateInviteLink(); - }, 3500); - }; - try { - if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { - await navigator.clipboard.writeText(value); - notify('Guest link copied.', 'success'); - return; - } - } catch (error) { - console.warn('Navigator clipboard copy failed', error); - } - try { - this.inviteLinkInput.focus(); - this.inviteLinkInput.select(); - const success = document.execCommand('copy'); - if (success) { - notify('Guest link copied.', 'success'); - } else { - notify('Select and copy the link manually.', 'warning'); - } - } catch (error) { - console.warn('Fallback copy failed', error); - notify('Select and copy the link manually.', 'warning'); - } - } - - getAdditionalRecordingParticipants() { - const extras = []; - this.virtualParticipants.forEach((participant) => { - if (participant && participant.stream) { - extras.push({ - ...participant, - }); - } - }); - return extras; - } - - async ensureAudioContextResumed() { - if (!this.audioContext) { - this.audioContext = this.createAudioContext(); - } - if (this.audioContext && typeof this.audioContext.resume === 'function' && this.audioContext.state === 'suspended') { - try { - await this.audioContext.resume(); - } catch (error) { - console.warn('Failed to resume audio context', error); - } - } - } - - setHostMicError(message) { - if (this.hostMicErrorNode) { - this.hostMicErrorNode.textContent = message || ''; - } - } - - updateHostMicUI() { - if (this.hostMicButton) { - if (this.hostMicBusy || this.recording) { - this.hostMicButton.disabled = true; - const busyLabel = this.hostMic?.active ? 'Disabling…' : 'Enabling…'; - this.hostMicButton.textContent = this.recording ? 'Host mic locked during record' : busyLabel; - } else { - this.hostMicButton.disabled = false; - this.hostMicButton.textContent = this.hostMic?.active ? 'Disable Host Mic' : 'Enable Host Mic'; - } - if (this.hostMic?.active) { - this.hostMicButton.classList.add('active'); - } else { - this.hostMicButton.classList.remove('active'); - } - } - if (this.hostMicStatusNode) { - if (this.hostMic?.active) { - this.hostMicStatusNode.textContent = 'Microphone live'; - this.hostMicStatusNode.dataset.state = 'active'; - } else { - this.hostMicStatusNode.textContent = 'Microphone idle'; - this.hostMicStatusNode.dataset.state = 'idle'; - } - } - } - - async handleHostMicToggle() { - if (this.recording) { - this.setHostMicError('Stop the recording to change the host mic.'); - return; - } - if (this.hostMicBusy) { - return; - } - if (this.hostMic?.active) { - await this.disableHostMic(); - } else { - await this.enableHostMic(); - } - } - - async enableHostMic() { - if (this.hostMic?.active) { - this.updateHostMicUI(); - return; - } - if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { - this.setHostMicError('Browser does not support microphone capture.'); - return; - } - this.hostMicBusy = true; - this.setHostMicError(''); - this.updateHostMicUI(); - try { - await this.ensureAudioContextResumed(); - const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); - const [track] = stream.getAudioTracks(); - if (!track) { - throw new Error('No audio track available.'); - } - const label = this.session?.label ? `${this.session.label} (Host)` : 'Host Mic'; - const participant = { - uuid: 'host-mic', - label, - stream, - streamID: 'host-mic', - status: 'connected', - audioLevel: 0, - isLocal: true, - kind: 'local', - role: 'host-mic', - }; - track.addEventListener('ended', () => { - if (this.hostMic?.track === track) { - this.disableHostMic(); - } - }); - this.hostMic = { - active: true, - stream, - track, - uuid: participant.uuid, - label: participant.label, - streamID: participant.streamID, - participant, - }; - this.virtualParticipants.set(participant.uuid, participant); - if (this.audioContext && track) { - try { - this.hostMicMeter = await monitorTrackLevel(this.audioContext, track, { - uuid: participant.uuid, - trackType: 'audio', - metadata: { label: participant.label, source: 'host' }, - }); - } catch (error) { - console.warn('Failed to attach host mic meter', error); - } - } - this.updateHostMicUI(); - this.refreshRoster(); - } catch (error) { - console.error('Failed to enable host microphone', error); - this.setHostMicError(error?.message || 'Unable to access microphone.'); - if (this.hostMic?.stream) { - try { - this.hostMic.stream.getTracks().forEach((mediaTrack) => mediaTrack.stop()); - } catch (stopError) { - console.warn('Failed to stop host mic stream after error', stopError); - } - } - this.hostMic = null; - this.virtualParticipants.delete('host-mic'); - this.updateHostMicUI(); - } finally { - this.hostMicBusy = false; - this.updateHostMicUI(); - } - } - - async disableHostMic() { - if (!this.hostMic?.active && !this.virtualParticipants.has('host-mic')) { - this.hostMic = null; - this.updateHostMicUI(); - return; - } - this.hostMicBusy = true; - this.updateHostMicUI(); - try { - if (this.hostMicMeter) { - try { - this.hostMicMeter.disconnect({ stopTrack: false }); - } catch (error) { - console.warn('Failed to disconnect host mic meter', error); - } - this.hostMicMeter = null; - } - if (this.hostMic?.stream) { - this.hostMic.stream.getTracks().forEach((track) => { - try { - track.stop(); - } catch (error) { - console.warn('Failed to stop host mic track', error); - } - }); - } - } finally { - this.virtualParticipants.delete('host-mic'); - this.hostMic = null; - this.hostMicBusy = false; - this.setHostMicError(''); - this.updateHostMicUI(); - this.applyMeterValue('host-mic', 0); - this.refreshRoster(); - } - } - - setCloudMessage(service, message, variant = 'info') { - const container = this.cloudLinkMessages?.[service]; - if (!container) { - return; - } - const target = this.cloudLinkMessageTextNodes?.[service] || container; - target.textContent = message || ''; - container.dataset.variant = message ? variant : ''; - } - - updateCloudLinkUI() { - const driveLinked = this.cloud?.hasDriveAccess(); - const dropboxLinked = this.cloud?.hasDropboxAccess(); - const cachedState = readCloudLinkStatus(); - if (!driveLinked && cachedState.drive && !isCloudLinkFresh(cachedState.drive)) { - markCloudUnlinked('drive'); - } - if (!dropboxLinked && cachedState.dropbox && !isCloudLinkFresh(cachedState.dropbox)) { - markCloudUnlinked('dropbox'); - } - - if (this.cloudLinkButtons.drive) { - this.cloudLinkButtons.drive.textContent = driveLinked ? 'Reauthorize Drive' : 'Link Google Drive'; - this.cloudLinkButtons.drive.disabled = Boolean(this.cloudBusy.drive) || this.recording; - this.cloudLinkButtons.drive.dataset.state = driveLinked ? 'linked' : 'idle'; - } - if (this.cloudLinkStatusNodes.drive) { - this.cloudLinkStatusNodes.drive.textContent = driveLinked ? 'Linked' : 'Not linked'; - this.cloudLinkStatusNodes.drive.dataset.state = driveLinked ? 'linked' : 'idle'; - } - if (driveLinked && this.pendingDriveUploads.length) { - this.flushPendingDriveUploads().catch((error) => { - console.warn('Pending Drive uploads failed to resume', error); - }); - } - - if (this.cloudLinkButtons.dropbox) { - this.cloudLinkButtons.dropbox.textContent = dropboxLinked ? 'Refresh Dropbox' : 'Link Dropbox'; - this.cloudLinkButtons.dropbox.disabled = Boolean(this.cloudBusy.dropbox) || this.recording; - this.cloudLinkButtons.dropbox.dataset.state = dropboxLinked ? 'linked' : 'idle'; - } - if (this.cloudLinkStatusNodes.dropbox) { - this.cloudLinkStatusNodes.dropbox.textContent = dropboxLinked ? 'Linked' : 'Not linked'; - this.cloudLinkStatusNodes.dropbox.dataset.state = dropboxLinked ? 'linked' : 'idle'; - } - if (this.dropboxTokenInput) { - this.dropboxTokenInput.disabled = Boolean(this.cloudBusy.dropbox) || this.recording; - } - this.updateAllDriveActions(); - } - - ensureDropboxTokenFallbackVisible({ focus = false, select = false } = {}) { - if (this.dropboxTokenRow) { - this.dropboxTokenRow.hidden = false; - this.dropboxTokenRow.classList.add('cloud-sync-token--visible'); - } - if (this.dropboxGuideRow) { - this.dropboxGuideRow.hidden = false; - this.dropboxGuideRow.classList.add('cloud-sync-token__guide--visible'); - } - if (focus && this.dropboxTokenInput) { - this.dropboxTokenInput.focus(); - if (select && typeof this.dropboxTokenInput.select === 'function') { - this.dropboxTokenInput.select(); - } - } - } - - hideDropboxTokenFallback() { - if (this.dropboxTokenRow) { - this.dropboxTokenRow.hidden = true; - this.dropboxTokenRow.classList.remove('cloud-sync-token--visible'); - } - if (this.dropboxGuideRow) { - this.dropboxGuideRow.hidden = true; - this.dropboxGuideRow.classList.remove('cloud-sync-token__guide--visible'); - } - if (this.dropboxTokenInput) { - this.dropboxTokenInput.value = ''; - } - } - - async handleDiskFolderSelection({ autoEnable = false } = {}) { - if (!STUDIO_DISK_FEATURE_FLAG || !this.diskFolderButton) { - return; - } - if (typeof window.showDirectoryPicker !== 'function') { - this.diskStatusNode.textContent = 'Local disk recording requires Chrome, Edge, or Arc.'; - this.diskStatusNode.dataset.state = 'error'; - return; - } - try { - this.diskFolderButton.disabled = true; - this.diskFolderButton.textContent = 'Waiting for folder…'; - await chooseDiskRecordingDirectory(); - const result = await verifyStoredDiskRecordingDirectory({ requestPermission: true }); - if (!result.ok) { - throw new Error(result.message || 'Folder verification failed.'); - } - if (autoEnable || isDiskRecordingEnabled()) { - setDiskRecordingEnabled(true); - this.diskRecordingEnabled = true; - } - this.diskFolderButton.textContent = 'Change folder'; - } catch (error) { - console.warn('Disk folder selection failed', error); - this.diskStatusNode.textContent = - error?.name === 'AbortError' || /cancel/i.test(error?.message || '') - ? 'Folder selection cancelled.' - : error?.message || 'Unable to select folder.'; - this.diskStatusNode.dataset.state = 'error'; - } finally { - this.diskFolderButton.disabled = false; - this.updateDiskRecordingUI(); - } - } - - async handleDiskToggle() { - if (!STUDIO_DISK_FEATURE_FLAG || !this.diskToggleButton) { - return; - } - if (typeof window.showDirectoryPicker !== 'function') { - this.diskStatusNode.textContent = 'Browser lacks File System Access API support.'; - this.diskStatusNode.dataset.state = 'error'; - return; - } - const meta = readDiskRecordingState(); - if (!meta.folderName) { - await this.handleDiskFolderSelection({ autoEnable: true }); - return; - } - const nextEnabled = !isDiskRecordingEnabled(); - if (nextEnabled) { - const result = await verifyStoredDiskRecordingDirectory({ requestPermission: true }); - if (!result.ok) { - this.diskStatusNode.textContent = result.message || 'Unable to access the selected folder.'; - this.diskStatusNode.dataset.state = 'error'; - setDiskRecordingEnabled(false); - this.diskRecordingEnabled = false; - this.updateDiskRecordingUI(); - return; - } - } - const finalState = setDiskRecordingEnabled(nextEnabled); - this.diskRecordingEnabled = Boolean(finalState.enabled); - this.updateDiskRecordingUI(); - } - - updateDiskRecordingUI() { - if (!STUDIO_DISK_FEATURE_FLAG || !this.diskControls) { - return; - } - const diskSupported = typeof window.showDirectoryPicker === 'function'; - const meta = readDiskRecordingState(); - const hasFolder = Boolean(meta.folderName); - const enabled = Boolean(meta.enabled && hasFolder); - this.diskRecordingEnabled = enabled; - if (this.diskToggleButton) { - this.diskToggleButton.disabled = !diskSupported; - this.diskToggleButton.dataset.state = enabled ? 'enabled' : 'disabled'; - this.diskToggleButton.textContent = enabled ? 'Local disk armed' : 'Setup local disk recording'; - this.diskToggleButton.setAttribute('aria-pressed', enabled ? 'true' : 'false'); - } - if (this.diskFolderButton) { - this.diskFolderButton.disabled = !diskSupported; - this.diskFolderButton.textContent = hasFolder ? 'Change folder' : diskSupported ? 'Choose folder' : 'Not supported'; - } - if (this.diskStatusNode) { - if (!diskSupported) { - this.diskStatusNode.textContent = 'Local disk recording requires the File System Access API.'; - this.diskStatusNode.dataset.state = 'error'; - } else if (!hasFolder) { - this.diskStatusNode.textContent = 'Folder not selected.'; - this.diskStatusNode.dataset.state = 'pending'; - } else if (meta.lastError) { - this.diskStatusNode.textContent = `${meta.folderName} — ${meta.lastError}`; - this.diskStatusNode.dataset.state = 'error'; - } else { - const verified = meta.lastVerifiedAt ? `Checked ${formatRelativeTime(meta.lastVerifiedAt)}.` : 'Ready to verify.'; - this.diskStatusNode.textContent = `${meta.folderName} · ${verified}`; - this.diskStatusNode.dataset.state = meta.lastVerifiedAt ? 'ready' : 'pending'; - } - } - this.updateReadinessSummary(); - } - - async ensureDiskCaptureReadiness({ interactive = false } = {}) { - if (!STUDIO_DISK_FEATURE_FLAG || !this.diskRecordingEnabled) { - return { enabled: false, ready: false }; - } - const result = await verifyStoredDiskRecordingDirectory({ requestPermission: interactive }); - if (!result.ok) { - if (this.diskStatusNode) { - this.diskStatusNode.textContent = result.message || 'Unable to access the selected folder.'; - this.diskStatusNode.dataset.state = 'error'; - } - setDiskRecordingEnabled(false); - this.diskRecordingEnabled = false; - this.updateDiskRecordingUI(); - return { - enabled: true, - ready: false, - error: new Error(result.message || 'Folder unavailable'), - }; - } - return { - enabled: true, - ready: true, - folderName: result.folderName, - verifiedAt: Date.now(), - }; - } - - async handleDriveLink() { - if (!this.cloud || this.cloudBusy.drive) { - return; - } - this.cloudBusy.drive = true; - this.updateCloudLinkUI(); - this.setCloudMessage('drive', 'Requesting Google authorization…', 'info'); - try { - const client = this.cloud.ensureDriveClient(); - if (!client) { - throw new Error('Google Drive integration is not available on this build.'); - } - if (typeof client.ensureInitialized === 'function') { - await client.ensureInitialized(); - } - if (typeof client.requestAccessToken === 'function') { - client.requestAccessToken(); - } - if (client.promise && typeof client.promise.then === 'function') { - await client.promise; - } else { - await new Promise((resolve) => setTimeout(resolve, 800)); - } - if (this.cloud.hasDriveAccess()) { - this.setCloudMessage('drive', 'Google Drive linked. Recordings will upload automatically.', 'success'); - const folder = this.session?.GDRIVE_FOLDERNAME || null; - markCloudLinked('drive', { folder }); - await this.flushPendingDriveUploads(); - } else { - this.setCloudMessage('drive', 'Check your popup blocker or try again.', 'warn'); - markCloudUnlinked('drive'); - } - } catch (error) { - console.error('Failed to link Google Drive', error); - this.setCloudMessage('drive', error?.message || 'Failed to link Google Drive.', 'error'); - markCloudUnlinked('drive'); - } finally { - this.cloudBusy.drive = false; - this.updateCloudFooter(); - } - } - - async handleDropboxLink() { - if (!this.cloud || this.cloudBusy.dropbox) { - return; - } - this.cloudBusy.dropbox = true; - this.updateCloudLinkUI(); - const providedToken = (this.dropboxTokenInput?.value || '').trim(); - if (providedToken) { - this.ensureDropboxTokenFallbackVisible(); - } - const interactive = !providedToken; - const hasExistingAccess = this.cloud?.hasDropboxAccess(); - const forceReauth = !providedToken && hasExistingAccess; - const pendingMessage = providedToken - ? 'Linking Dropbox with the provided token…' - : hasExistingAccess - ? 'Refreshing Dropbox session…' - : 'Waiting for the Dropbox popup to complete…'; - this.setCloudMessage('dropbox', pendingMessage, 'info'); - try { - if (typeof window.setupDropbox !== 'function') { - throw new Error('Dropbox uploader is not available in this build.'); - } - const client = await this.cloud.ensureDropboxClient(providedToken || undefined, { interactive, forceReauth }); - if (client) { - this.setCloudMessage('dropbox', 'Dropbox linked. Recordings will upload automatically.', 'success'); - if (this.dropboxTokenInput) { - this.dropboxTokenInput.value = ''; - } - if (!providedToken) { - this.hideDropboxTokenFallback(); - } - markCloudLinked('dropbox'); - } else { - markCloudUnlinked('dropbox'); - if (providedToken) { - this.setCloudMessage('dropbox', 'Dropbox rejected the provided token. Double-check and try again.', 'error'); - this.ensureDropboxTokenFallbackVisible({ focus: true, select: true }); - } else { - this.setCloudMessage('dropbox', 'Dropbox authorization was cancelled. Check your popup blocker and try again.', 'warn'); - this.ensureDropboxTokenFallbackVisible({ focus: true }); - } - } - } catch (error) { - console.error('Failed to init Dropbox', error); - this.setCloudMessage('dropbox', error?.message || 'Unable to initialise Dropbox.', 'error'); - this.ensureDropboxTokenFallbackVisible({ focus: true }); - markCloudUnlinked('dropbox'); - } finally { - this.cloudBusy.dropbox = false; - this.updateCloudFooter(); - } - } - - createAudioContext() { - const AudioCtx = window.AudioContext || window.webkitAudioContext; - if (!AudioCtx) { - return null; - } - return new AudioCtx(); - } - - buildLayout() { - if (document.getElementById(STUDIO_ROOT_ID)) { - return; - } - - const root = createElement('div', '', { id: STUDIO_ROOT_ID }); - - // Header - const header = createElement('header', 'podcast-header'); - const title = createElement('h1', '', { text: 'Podcast Control Room' }); - const statusPill = createElement('div', 'podcast-status-pill'); - statusPill.innerHTML = 'Live-ready'; - header.append(title, statusPill); - - // Main layout - const main = createElement('main', 'podcast-main'); - - // Left column (roster + markers) - const rosterColumn = createElement('div', 'podcast-roster'); - const rosterPanel = createElement('section', 'podcast-panel'); - rosterPanel.append(createElement('h2', '', { text: 'Talent Roster' })); - this.rosterList = createElement('div', 'roster-list'); - rosterPanel.append(this.rosterList); - - const markersPanel = createElement('section', 'podcast-panel'); - markersPanel.append(createElement('h2', '', { text: 'Session Markers' })); - this.markerLog = createElement('div', 'marker-log'); - const emptyMarkers = createElement('div', 'empty-state', { text: 'Tap “Add Marker” to drop cue points during recording.' }); - emptyMarkers.dataset.empty = 'true'; - this.markerLog.append(emptyMarkers); - markersPanel.append(this.markerLog); - - rosterColumn.append(rosterPanel, markersPanel); - - // Right column (controls + timeline) - const consoleColumn = createElement('div', 'podcast-console'); - const consoleGrid = createElement('div', 'podcast-console-grid'); - consoleColumn.append(consoleGrid); - - const invitePanel = createElement('section', 'podcast-panel invite-panel'); - invitePanel.classList.add('console-grid__span-2'); - invitePanel.append(createElement('h2', '', { text: 'Guest Invites' })); - const inviteIntro = createElement('p', 'invite-copy', { - text: 'Share a pro audio-ready link with guests. Tweak processing flags before copying.', - }); - const inviteLinkRow = createElement('div', 'invite-link-row'); - this.inviteLinkInput = createElement('input', 'invite-link-input', { - type: 'text', - readonly: 'true', - value: '', - }); - this.inviteLinkInput.addEventListener('focus', () => { - try { - this.inviteLinkInput.select(); - } catch (error) { - console.warn('Invite link select failed', error); - } - }); - this.inviteCopyButton = createElement('button', 'invite-link-copy', { type: 'button', text: 'Copy link' }); - this.inviteCopyButton.addEventListener('click', () => this.copyInviteLink()); - inviteLinkRow.append(this.inviteLinkInput, this.inviteCopyButton); - this.inviteStatusNode = createElement('div', 'invite-status'); - - const inviteOptions = createElement('div', 'invite-options'); - const optionDefs = [ - { key: 'proAudio', label: 'Enable pro audio (stereo, 256 kbps)', defaultChecked: true }, - { key: 'disableAec', label: 'Disable echo cancellation', defaultChecked: true }, - { key: 'disableDenoise', label: 'Disable noise reduction', defaultChecked: true }, - { key: 'disableAgc', label: 'Disable auto gain control', defaultChecked: true }, - ]; - optionDefs.forEach((option) => { - const optionLabel = createElement('label', 'invite-option'); - const checkbox = createElement('input', 'invite-option__checkbox', { type: 'checkbox' }); - checkbox.checked = option.defaultChecked; - checkbox.addEventListener('change', () => this.updateInviteLink()); - optionLabel.append(checkbox, createElement('span', 'invite-option__label', { text: option.label })); - inviteOptions.append(optionLabel); - this.inviteOptionNodes[option.key] = checkbox; - }); - - invitePanel.append(inviteIntro, inviteLinkRow, this.inviteStatusNode, inviteOptions); - - const sessionToolsPanel = createElement('section', 'podcast-panel session-tools'); - sessionToolsPanel.classList.add('console-grid__span-2'); - const sessionToolsGrid = createElement('div', 'session-tools__grid'); - sessionToolsPanel.append(sessionToolsGrid); - - const hostCard = createElement('div', 'session-tool session-tool--host'); - hostCard.append(createElement('h2', 'session-tool__title', { text: 'Host Input' })); - const hostControls = createElement('div', 'host-input-content'); - this.hostMicButton = createElement('button', 'host-input-toggle', { text: 'Enable Host Mic' }); - this.hostMicButton.addEventListener('click', () => this.handleHostMicToggle()); - this.hostMicStatusNode = createElement('div', 'host-input-status', { text: 'Microphone idle' }); - hostControls.append(this.hostMicButton, this.hostMicStatusNode); - this.hostMicErrorNode = createElement('div', 'host-input-error'); - hostCard.append(hostControls, this.hostMicErrorNode); - sessionToolsGrid.append(hostCard); - - const controlCard = createElement('div', 'session-tool session-tool--control'); - controlCard.append(createElement('h2', 'session-tool__title', { text: 'Session Control' })); - const transportStrip = createElement('div', 'transport-strip'); - this.recordButton = createElement('button', '', { text: 'Arm & Record' }); - this.recordButton.addEventListener('click', () => this.handleRecordToggle()); - - this.markerButton = createElement('button', '', { text: 'Add Marker' }); - this.markerButton.disabled = true; - this.markerButton.addEventListener('click', () => this.addMarker()); - - const transportButtons = createElement('div', 'transport-buttons'); - transportButtons.append(this.recordButton, this.markerButton); - this.sessionInfo = createElement('div', 'session-info', { text: 'Room: ' + (this.roomName || '—') }); - transportStrip.append(transportButtons); - transportStrip.append(this.sessionInfo); - this.recordingStatusNode = createElement('div', 'session-recording-status', { text: 'Recording idle' }); - this.recordingStatusNode.dataset.state = 'idle'; - transportStrip.append(this.recordingStatusNode); - controlCard.append(transportStrip); - sessionToolsGrid.append(controlCard); - - const cloudCard = createElement('div', 'session-tool session-tool--cloud'); - cloudCard.append(createElement('h2', 'session-tool__title', { text: 'Cloud Sync' })); - const cloudList = createElement('div', 'cloud-sync-list'); - const driveRow = createElement('div', 'cloud-sync-list__row'); - driveRow.append(createElement('div', 'cloud-sync-list__label', { text: 'Google Drive' })); - const driveActions = createElement('div', 'cloud-sync-list__actions'); - this.cloudLinkButtons.drive = createElement('button', 'cloud-sync-list__button', { text: 'Link Google Drive' }); - this.cloudLinkButtons.drive.addEventListener('click', () => this.handleDriveLink()); - this.cloudLinkButtons.drive.dataset.state = 'idle'; - this.cloudLinkStatusNodes.drive = createElement('span', 'cloud-sync-list__status', { text: 'Not linked' }); - this.cloudLinkStatusNodes.drive.dataset.state = 'idle'; - driveActions.append(this.cloudLinkButtons.drive, this.cloudLinkStatusNodes.drive); - driveRow.append(driveActions); - this.cloudLinkMessages.drive = createElement('div', 'cloud-sync-list__hint'); - this.cloudLinkMessages.drive.dataset.variant = ''; - const driveMessageText = createElement('span', 'cloud-sync-list__hint-text'); - this.cloudLinkMessages.drive.append(driveMessageText); - this.cloudLinkMessageTextNodes.drive = driveMessageText; - cloudList.append(driveRow, this.cloudLinkMessages.drive); - - const dropboxRow = createElement('div', 'cloud-sync-list__row'); - dropboxRow.append(createElement('div', 'cloud-sync-list__label', { text: 'Dropbox' })); - const dropboxActions = createElement('div', 'cloud-sync-list__actions'); - this.cloudLinkButtons.dropbox = createElement('button', 'cloud-sync-list__button', { text: 'Link Dropbox' }); - this.cloudLinkButtons.dropbox.addEventListener('click', () => this.handleDropboxLink()); - this.cloudLinkButtons.dropbox.dataset.state = 'idle'; - this.cloudLinkStatusNodes.dropbox = createElement('span', 'cloud-sync-list__status', { text: 'Not linked' }); - this.cloudLinkStatusNodes.dropbox.dataset.state = 'idle'; - dropboxActions.append(this.cloudLinkButtons.dropbox, this.cloudLinkStatusNodes.dropbox); - dropboxRow.append(dropboxActions); - this.cloudLinkMessages.dropbox = createElement('div', 'cloud-sync-list__hint'); - this.cloudLinkMessages.dropbox.dataset.variant = ''; - const dropboxMessageText = createElement('span', 'cloud-sync-list__hint-text'); - this.cloudLinkMessages.dropbox.append(dropboxMessageText); - this.cloudLinkMessageTextNodes.dropbox = dropboxMessageText; - const tokenFieldId = 'podcast-dropbox-token'; - const dropboxTokenRow = createElement('div', 'cloud-sync-token'); - this.dropboxTokenRow = dropboxTokenRow; - const tokenLabel = createElement('label', 'cloud-sync-token__label', { text: 'Access token' }); - tokenLabel.setAttribute('for', tokenFieldId); - this.dropboxTokenInput = createElement('input', 'cloud-sync-token__input', { - type: 'password', - placeholder: 'Paste Dropbox personal access token', - id: tokenFieldId, - spellcheck: 'false', - autocapitalize: 'none', - autocomplete: 'off', - }); - dropboxTokenRow.append(tokenLabel, this.dropboxTokenInput); - const dropboxGuideRow = createElement('div', 'cloud-sync-token__guide'); - this.dropboxGuideRow = dropboxGuideRow; - const guideLink = createElement('a', 'cloud-sync-guide-link', { - text: 'Open the Dropbox setup guide', - href: DROPBOX_GUIDE_URL, - target: '_blank', - rel: 'noopener', - }); - dropboxGuideRow.append('Need a token? ', guideLink); - this.cloudLinkMessages.dropbox.append(dropboxTokenRow, dropboxGuideRow); - this.hideDropboxTokenFallback(); - cloudList.append(dropboxRow, this.cloudLinkMessages.dropbox); - const cloudSummary = createElement('div', 'cloud-sync-summary'); - this.cloudSummaryNode = createElement('div', 'cloud-sync-summary__item', { text: 'Cloud uploads: pending' }); - this.cloudSummaryNode.dataset.state = 'pending'; - const diskSummaryText = STUDIO_DISK_FEATURE_FLAG - ? 'Disk capture: checking browser support…' - : 'Disk capture disabled (?studioiso=0)'; - this.diskSummaryNode = createElement('div', 'cloud-sync-summary__item', { text: diskSummaryText }); - this.diskSummaryNode.dataset.state = STUDIO_DISK_FEATURE_FLAG ? 'pending' : 'disabled'; - cloudSummary.append(this.cloudSummaryNode, this.diskSummaryNode); - - const cloudProgress = createElement('div', 'cloud-sync-progress'); - ['drive', 'dropbox'].forEach((service) => { - const label = this.describeService(service); - const item = createElement('div', 'cloud-sync-progress__item', { text: `${label} uploads idle` }); - item.dataset.service = service; - item.dataset.state = 'idle'; - cloudProgress.append(item); - this.cloudProgressNodes[service] = item; - }); - - cloudCard.append(cloudList, cloudSummary, cloudProgress); - sessionToolsGrid.append(cloudCard); - - if (STUDIO_DISK_FEATURE_FLAG) { - const diskCard = createElement('div', 'session-tool session-tool--disk'); - diskCard.append(createElement('h2', 'session-tool__title', { text: 'Local Disk Capture' })); - const diskSupported = typeof window.showDirectoryPicker === 'function'; - const diskList = createElement('div', 'cloud-sync-list cloud-sync-list--disk'); - this.diskControls = diskList; - this.diskToggleButton = createElement('button', 'transport-disk__toggle', { - type: 'button', - text: 'Setup local disk recording', - }); - this.diskToggleButton.addEventListener('click', () => this.handleDiskToggle()); - this.diskFolderButton = createElement('button', 'transport-disk__folder', { - type: 'button', - text: diskSupported ? 'Choose folder' : 'Not supported', - }); - this.diskFolderButton.disabled = !diskSupported; - if (diskSupported) { - this.diskFolderButton.addEventListener('click', () => this.handleDiskFolderSelection()); - } - this.diskStatusNode = createElement('div', 'transport-disk__status', { - text: diskSupported ? 'Folder not selected' : 'File System Access API unavailable', - }); - diskList.append(this.diskToggleButton, this.diskFolderButton, this.diskStatusNode); - const diskHintWrapper = createElement('div', 'transport-disk transport-disk--panel'); - const diskHint = createElement('p', 'transport-disk__hint', { - text: diskSupported - ? 'Select a folder per session; browsers may prompt again if permissions expire.' - : 'Use Chromium-based browsers for File System Access support.', - }); - diskHintWrapper.append(diskHint); - diskCard.append(diskList, diskHintWrapper); - sessionToolsGrid.append(diskCard); - this.updateDiskRecordingUI(); - } - - const timelinePanel = createElement('section', 'podcast-panel timeline-shell'); - timelinePanel.classList.add('console-grid__span-2'); - timelinePanel.append(createElement('h2', '', { text: 'Timeline & Outputs' })); - this.outputsContainer = createElement('div', 'timeline-surface'); - timelinePanel.append(this.outputsContainer); - this.showOutputsMessage('Recordings and cue points will appear here.'); - - const chatPanel = createElement('section', 'podcast-panel chat-panel'); - chatPanel.dataset.collapsed = 'false'; - this.chatPanel = chatPanel; - const chatHeaderRow = createElement('div', 'chat-panel__header'); - const chatTitle = createElement('h2', '', { text: 'Control Room Chat' }); - const chatActions = createElement('div', 'chat-panel__actions'); - this.chatPopoutButton = createElement('button', 'chat-panel__action', { type: 'button', text: 'Pop out' }); - this.chatPopoutButton.addEventListener('click', () => this.handleChatPopout()); - this.chatCollapseButton = createElement('button', 'chat-panel__action chat-panel__toggle', { - type: 'button', - text: 'Hide chat', - }); - this.chatCollapseButton.setAttribute('aria-expanded', 'true'); - this.chatCollapseButton.addEventListener('click', () => this.toggleChatPanel()); - chatActions.append(this.chatPopoutButton, this.chatCollapseButton); - chatHeaderRow.append(chatTitle, chatActions); - chatPanel.append(chatHeaderRow); - - const chatBody = createElement('div', 'chat-panel__body'); - const legacyChat = document.getElementById('chatModule'); - if (legacyChat) { - this.chatPlaceholder = document.createElement('div'); - this.chatPlaceholder.dataset.podcastPlaceholder = 'chat-module'; - if (legacyChat.parentNode) { - legacyChat.parentNode.insertBefore(this.chatPlaceholder, legacyChat); - } - legacyChat.classList.remove('hidden'); - legacyChat.dataset.podcastOverlay = 'true'; - const legacyHeader = legacyChat.querySelector('.chat-header'); - if (legacyHeader) { - const popLink = legacyHeader.querySelector('#popOutChat'); - if (popLink) { - this.chatPopoutAnchor = popLink; - popLink.style.display = 'none'; - } - const closeLink = legacyHeader.querySelector('#closeChat'); - if (closeLink) { - closeLink.style.display = 'none'; - } - legacyHeader.dataset.podcastDisplay = legacyHeader.style.display || ''; - legacyHeader.style.display = 'none'; - } - const legacyResizer = legacyChat.querySelector('.resizer'); - if (legacyResizer) { - legacyResizer.dataset.podcastDisplay = legacyResizer.style.display || ''; - legacyResizer.style.display = 'none'; - } - legacyChat.style.position = 'relative'; - legacyChat.style.right = 'auto'; - legacyChat.style.left = 'auto'; - legacyChat.style.bottom = 'auto'; - legacyChat.style.top = 'auto'; - legacyChat.style.zIndex = 'auto'; - legacyChat.style.maxWidth = '100%'; - legacyChat.style.width = '100%'; - legacyChat.style.height = 'auto'; - legacyChat.style.maxHeight = '320px'; - legacyChat.style.overflow = 'hidden'; - legacyChat.style.margin = '0'; - chatBody.append(legacyChat); - this.chatModule = legacyChat; - } else { - chatBody.append(createElement('div', 'chat-panel__empty', { text: 'Chat initialising…' })); - } - if (this.chatPopoutButton) { - const hasPopout = Boolean(this.chatPopoutAnchor || typeof window.createPopoutChat === 'function'); - this.chatPopoutButton.disabled = !hasPopout; - if (!hasPopout) { - this.chatPopoutButton.textContent = 'Pop out unavailable'; - } - } - chatPanel.append(chatBody); - this.chatCollapsedHint = createElement('div', 'chat-panel__collapsed-hint', { - text: 'Chat hidden. Click “Show chat” to reopen.', - }); - this.chatCollapsedHint.style.display = 'none'; - chatPanel.append(this.chatCollapsedHint); - - if (this.chatModule) { - this.chatModule.classList.remove('hidden'); - this.chatModule.style.display = ''; - this.chatModule.dataset.podcastEmbedded = 'true'; - } - - consoleGrid.append(invitePanel); - consoleGrid.append(sessionToolsPanel); - consoleGrid.append(timelinePanel); - chatPanel.classList.add('console-grid__span-2'); - consoleGrid.append(chatPanel); - - main.append(rosterColumn, consoleColumn); - - // Footer - const footer = createElement('footer', 'podcast-footer'); - footer.innerHTML = ` -
Powered by VDO.Ninja • Low-latency P2P backbone intact
-
- ${this.cloud?.hasDriveAccess() ? 'Google Drive linked' : 'Drive link pending'} - ${this.cloud?.hasDropboxAccess() ? 'Dropbox linked' : 'Dropbox link pending'} -
- `; - - root.append(header, main, footer); - document.body.appendChild(root); - - this.driveStatusNode = document.getElementById('podcast-cloud-drive'); - this.dropboxStatusNode = document.getElementById('podcast-cloud-dropbox'); - this.updateHostMicUI(); - this.setHostMicError(''); - this.updateCloudLinkUI(); - this.updateReadinessSummary(); - this.setCloudMessage('drive', ''); - this.setCloudMessage('dropbox', ''); - this.updateInviteLink(); - this.toggleChatPanel(false); - requestAnimationFrame(() => this.toggleChatPanel(false)); - } - - handleChatPopout() { - if (this.chatPopoutAnchor && typeof this.chatPopoutAnchor.click === 'function') { - try { - this.chatPopoutAnchor.click(); - return; - } catch (error) { - console.warn('Legacy chat pop-out click failed', error); - } - } - if (typeof window.createPopoutChat === 'function') { - try { - window.createPopoutChat(); - return; - } catch (error) { - console.warn('createPopoutChat invocation failed', error); - } - } - try { - window.open(window.location.href, '_blank', 'noopener'); - } catch (error) { - console.warn('Fallback chat pop-out failed', error); - } - } - - toggleChatPanel(forceCollapsed) { - const shouldCollapse = typeof forceCollapsed === 'boolean' ? forceCollapsed : !this.chatCollapsed; - this.chatCollapsed = Boolean(shouldCollapse); - if (this.chatPanel) { - this.chatPanel.dataset.collapsed = this.chatCollapsed ? 'true' : 'false'; - } - if (this.chatCollapseButton) { - this.chatCollapseButton.textContent = this.chatCollapsed ? 'Show chat' : 'Hide chat'; - this.chatCollapseButton.setAttribute('aria-expanded', this.chatCollapsed ? 'false' : 'true'); - } - if (this.chatCollapsedHint) { - this.chatCollapsedHint.style.display = this.chatCollapsed ? '' : 'none'; - } - if (this.chatModule) { - if (this.chatCollapsed) { - this.chatModule.classList.add('hidden'); - } else { - this.chatModule.classList.remove('hidden'); - } - } - if (this.session && typeof this.session.chat !== 'undefined') { - this.session.chat = !this.chatCollapsed; - } - } - - attachRecorderEvents() { - this.recorder.addEventListener('start', () => { - if (this.abortUploadsController) { - this.abortUploadsController.abort(); - } - this.abortUploadsController = new AbortController(); - this.cleanupDownloadUrls(); - this.trackRuntimeStats.clear(); - this.trackLevelNodes.clear(); - this.teardownSpectrograms(); - this.outputIndicators.clear(); - this.recording = true; - this.recordStartedAt = Date.now(); - this.recordButton.classList.add('recording'); - this.recordButton.textContent = 'Stop Recording'; - this.markerButton.disabled = false; - this.showOutputsMessage('Recording… tracks will appear as audio arrives.'); - this.updateHostMicUI(); - this.setUploadProgressPending(true); - if (this.recordingPlan?.sync) { - this.recordingPlan.sync.start = { - wallClock: this.recordStartedAt, - highRes: snapshotHighResClock(), - }; - } - this.logRecordingEvent('record:start', { sessionId: this.recordingSessionId }); - this.updateRecordingPlanStatus('started', { events: this.recordingPlan?.events || [] }); - this.setRecordingStatus('Recording in progress', 'active'); - }); - - this.recorder.addEventListener('chunk', (event) => { - const { participant, trackType, channelIndex } = event.detail || {}; - if (!participant || !trackType) { - return; - } - const channelKey = typeof channelIndex === 'number' ? channelIndex : 0; - const key = this.buildTrackKey(participant.uuid, trackType, channelKey); - if (!key) { - return; - } - const indicator = this.ensureOutputIndicator(key, participant, trackType, channelKey); - if (!indicator) { - return; - } - indicator.badge.textContent = 'Recording'; - indicator.wrapper.dataset.state = 'recording'; - this.updateRecordingRuntimeMetrics(key, indicator, event.detail); - this.trackManifestChunk(event.detail); - }); - - this.recorder.addEventListener('meter-ready', (event) => { - const { participant, trackType, channelIndex, meter } = event.detail || {}; - if (!participant?.uuid || trackType !== 'audio') { - return; - } - const key = this.buildTrackKey(participant.uuid, trackType, channelIndex); - if (!key) { - return; - } - const indicator = this.outputIndicators.get(key); - if (!indicator) { - return; - } - this.attachSpectrogram(key, indicator, participant, trackType, channelIndex, meter); - }); - - this.recorder.addEventListener('error', (event) => { - console.error('Recorder error', event.detail); - this.setStatusMessage('Recorder error: ' + (event.detail?.message || 'unknown')); - }); - - this.recorder.addEventListener('stop', (event) => { - this.recording = false; - this.recordButton.classList.remove('recording'); - this.recordButton.textContent = 'Arm & Record'; - this.markerButton.disabled = true; - this.showOutputsMessage('Finalising recordings…'); - this.trackLevelNodes.clear(); - this.teardownSpectrograms(); - this.presentRecordings(event.detail?.files); - this.outputIndicators.clear(); - this.trackRuntimeStats.clear(); - this.updateHostMicUI(); - if (this.recordingPlan?.sync) { - this.recordingPlan.sync.stop = { - wallClock: Date.now(), - highRes: snapshotHighResClock(), - }; - } - if (this.recordingPlan) { - this.recordingPlan.files = this.summariseRecordingFiles(event.detail?.files); - this.logRecordingEvent('record:stop', { fileCount: this.recordingPlan?.files?.length || 0 }); - this.updateRecordingPlanStatus('stopped', { - files: this.recordingPlan.files, - events: this.recordingPlan.events, - }); - } - this.setRecordingStatus('Recording idle', 'idle'); - }); - } - - ensureOutputIndicator(key, participant, trackType, channelIndex = 0) { - if (!this.outputsContainer) { - return null; - } - if (this.outputIndicators.has(key)) { - return this.outputIndicators.get(key); - } - - this.prepareTracklistSurface(); - - if (!this.outputsContainer.dataset.hasTracks) { - this.outputsContainer.innerHTML = ''; - this.outputsContainer.dataset.hasTracks = 'true'; - } - - const wrapper = createElement('div', 'timeline-track'); - wrapper.dataset.key = key; - wrapper.dataset.trackType = trackType; - wrapper.dataset.participant = participant.uuid || ''; - wrapper.dataset.state = 'armed'; - - const header = createElement('div', 'timeline-track__header'); - const titleGroup = createElement('div', 'timeline-track__title-group'); - const title = createElement('div', 'timeline-track__title', { text: participant.label || participant.uuid || 'Guest' }); - const descriptorParts = []; - if (participant.external || participant.uuid === 'host-mic') { - descriptorParts.push('Local input'); - } else if (participant.streamID) { - descriptorParts.push(`Stream ${participant.streamID}`); - } - descriptorParts.push(trackType ? trackType.toUpperCase() : 'AUDIO'); - descriptorParts.push(`Channel ${channelIndex + 1}`); - const subtitle = createElement('div', 'timeline-track__subtitle', { - text: descriptorParts.filter(Boolean).join(' • '), - }); - titleGroup.append(title, subtitle); - const badge = createElement('span', 'timeline-track__badge', { text: 'Arming' }); - header.append(titleGroup, badge); - - const metrics = createElement('div', 'timeline-track__metrics'); - const inboundMetric = createElement('span', 'timeline-track__metric timeline-track__metric--inbound', { - text: participant.external || participant.uuid === 'host-mic' ? 'Inbound: Local capture' : 'Inbound: pending…', - }); - const recordMetric = createElement('span', 'timeline-track__metric timeline-track__metric--recording', { - text: 'Recording: waiting…', - }); - metrics.append(inboundMetric, recordMetric); - - const waveform = createElement('div', 'timeline-track__waveform'); - const spectrogramCanvas = document.createElement('canvas'); - spectrogramCanvas.className = 'timeline-track__spectrogram'; - const waveFill = createElement('div', 'timeline-track__wavefill'); - waveform.append(spectrogramCanvas, waveFill); - - wrapper.append(header, metrics, waveform); - this.outputsContainer.append(wrapper); - - const indicator = { - key, - wrapper, - badge, - inboundMetric, - recordMetric, - waveFill, - spectrogramCanvas, - participant, - trackType, - channelIndex, - }; - - this.outputIndicators.set(key, indicator); - this.registerTrackLevelNode(participant.uuid, waveFill); - this.updateTrackInboundMetric(participant.uuid); - this.attachSpectrogram(key, indicator, participant, trackType, channelIndex); - return indicator; - } - - setStatusMessage(message) { - this.showOutputsMessage(message); - } - - showOutputsMessage(text) { - if (!this.outputsContainer) { - return; - } - this.outputsContainer.dataset.mode = 'message'; - this.outputsContainer.dataset.hasTracks = ''; - this.outputsContainer.classList.remove('timeline-tracklist'); - this.outputsContainer.classList.remove('timeline-results'); - this.outputsContainer.innerHTML = ''; - if (typeof text === 'string' && text.trim()) { - this.outputsContainer.append(createElement('div', 'timeline-placeholder', { text })); - } else { - this.outputsContainer.append(createElement('div', 'timeline-placeholder', { text: '' })); - } - } - - prepareTracklistSurface({ reset = false } = {}) { - if (!this.outputsContainer) { - return; - } - const switchingMode = this.outputsContainer.dataset.mode !== 'recording'; - if (switchingMode || reset) { - this.outputsContainer.innerHTML = ''; - this.outputsContainer.dataset.hasTracks = ''; - } - this.outputsContainer.dataset.mode = 'recording'; - this.outputsContainer.classList.add('timeline-tracklist'); - this.outputsContainer.classList.remove('timeline-results'); - } - - buildTrackKey(uuid, trackType, channelIndex = 0) { - if (!uuid || !trackType) { - return ''; - } - const index = typeof channelIndex === 'number' ? channelIndex : 0; - return `${uuid}-${trackType}-${index}`; - } - - getMeterForTrack(uuid, trackType, channelIndex = 0) { - if (!this.recorder || typeof this.recorder.getTrackMeter !== 'function') { - return null; - } - return this.recorder.getTrackMeter(uuid, trackType, channelIndex); - } - - attachSpectrogram(key, indicator, participant, trackType, channelIndex, meterOverride = null) { - if (!key || trackType !== 'audio' || !indicator?.spectrogramCanvas) { - return; - } - let renderer = this.spectrograms.get(key); - if (!renderer) { - renderer = new SpectrogramRenderer(indicator.spectrogramCanvas); - this.spectrograms.set(key, renderer); - } - if (!participant?.uuid) { - return; - } - const meter = meterOverride || this.getMeterForTrack(participant.uuid, trackType, channelIndex); - if (meter?.analyser) { - renderer.setAnalyser(meter.analyser); - } - } - - teardownSpectrograms() { - if (!this.spectrograms) { - return; - } - this.spectrograms.forEach((renderer) => { - if (renderer && typeof renderer.destroy === 'function') { - renderer.destroy(); - } - }); - this.spectrograms.clear(); - } - - registerTrackLevelNode(uuid, node) { - if (!uuid || !node) { - return; - } - if (!this.trackLevelNodes.has(uuid)) { - this.trackLevelNodes.set(uuid, new Set()); - } - this.trackLevelNodes.get(uuid).add(node); - } - - updateTrackLevelVisual(uuid, level) { - if (!uuid) { - return; - } - const nodes = this.trackLevelNodes.get(uuid); - if (!nodes || !nodes.size) { - return; - } - const normalized = Math.max(0.08, Math.min(1, (level || 0) / 100)); - nodes.forEach((node) => { - if (!node) { - return; - } - node.style.transform = `scaleY(${normalized})`; - node.style.opacity = level > 3 ? '0.95' : '0.45'; - }); - } - - captureParticipantMetrics(participant) { - if (!participant?.uuid) { - return; - } - const next = { ...(this.participantMetrics.get(participant.uuid) || {}) }; - if (typeof participant.audioBitrateKbps === 'number' && participant.audioBitrateKbps >= 0) { - next.audioBitrateKbps = participant.audioBitrateKbps; - } - if (participant.audioCodec) { - next.audioCodec = participant.audioCodec; - } - if (participant.external || participant.uuid === 'host-mic') { - next.local = true; - } - this.participantMetrics.set(participant.uuid, next); - this.updateTrackInboundMetric(participant.uuid, next); - } - - updateTrackInboundMetric(uuid, metrics = this.participantMetrics.get(uuid)) { - if (!uuid) { - return; - } - const resolvedMetrics = metrics || null; - const indicators = this.outputIndicators || new Map(); - indicators.forEach((indicator) => { - if (!indicator || !indicator.participant || indicator.participant.uuid !== uuid) { - return; - } - const node = indicator.inboundMetric; - if (!node) { - return; - } - if (resolvedMetrics?.local) { - node.textContent = 'Inbound: Local capture'; - return; - } - const parts = []; - if (resolvedMetrics && typeof resolvedMetrics.audioBitrateKbps === 'number' && resolvedMetrics.audioBitrateKbps > 0) { - const formatted = this.formatBitrate(resolvedMetrics.audioBitrateKbps); - if (formatted) { - parts.push(formatted); - } - } - if (resolvedMetrics?.audioCodec) { - parts.push(resolvedMetrics.audioCodec.toUpperCase()); - } - node.textContent = parts.length ? `Inbound: ${parts.join(' • ')}` : 'Inbound: pending…'; - }); - } - - updateRecordingRuntimeMetrics(key, indicator, detail) { - if (!indicator) { - return; - } - const runtime = this.trackRuntimeStats.get(key) || { - bytes: 0, - startedAt: Date.now(), - lastUpdate: Date.now(), - }; - const chunk = detail?.data; - if (chunk && typeof chunk.size === 'number') { - runtime.bytes += chunk.size; - } - const now = Date.now(); - if (!runtime.startedAt) { - runtime.startedAt = now; - } - runtime.lastUpdate = now; - const elapsedMs = Math.max(1, now - runtime.startedAt); - const kbps = runtime.bytes ? (runtime.bytes * 8) / elapsedMs : 0; - const durationSeconds = (now - runtime.startedAt) / 1000; - const sampleRate = this.recorder?.options?.targetSampleRate || 48000; - const sampleRateLabel = - sampleRate >= 1000 - ? `${(sampleRate / 1000).toFixed(sampleRate % 1000 === 0 ? 0 : 1)} kHz` - : `${sampleRate} Hz`; - const bitrateLabel = kbps > 0 ? `${Math.round(kbps)} kbps` : 'estimating…'; - const durationLabel = this.formatDuration(durationSeconds); - indicator.recordMetric.textContent = `Recording: ${bitrateLabel} • WAV ${sampleRateLabel} • ${durationLabel}`; - this.trackRuntimeStats.set(key, runtime); - } - - buildRecordingPlanContext({ diskInfo } = {}) { - const now = Date.now(); - const cloudSnapshot = readCloudLinkStatus(); - const plan = { - sessionId: createRecordingSessionId(), - conductor: 'studio', - preparedAt: now, - disk: { - enabled: Boolean(diskInfo?.ready), - folderName: diskInfo?.folderName || null, - verifiedAt: diskInfo?.verifiedAt || null, - }, - cloud: { - driveLinked: Boolean(this.cloud?.hasDriveAccess() || cloudSnapshot.drive), - dropboxLinked: Boolean(this.cloud?.hasDropboxAccess() || cloudSnapshot.dropbox), - snapshot: cloudSnapshot, - }, - sync: { - prepared: snapshotHighResClock(), - start: null, - stop: null, - }, - participants: {}, - files: [], - events: [], - }; - this.recordingPlan = plan; - this.recordingSessionId = plan.sessionId; - this.logRecordingEvent('record:plan', { sessionId: plan.sessionId }); - dispatchStudioEvent(PODCAST_RECORD_PLAN_EVENT, { plan }); - this.setRecordingStatus('Recording plan armed', 'armed'); - return plan; - } - - updateRecordingPlanStatus(status, extra = {}) { - if (!this.recordingPlan) { - return; - } - const detail = { - status, - plan: this.recordingPlan, - timestamp: Date.now(), - ...extra, - }; - dispatchStudioEvent(PODCAST_RECORD_STATUS_EVENT, detail); - } - - trackManifestChunk(detail) { - if (!this.recordingPlan || !detail?.participant?.uuid) { - return; - } - const participantId = detail.participant.uuid; - if (!this.recordingPlan.participants[participantId]) { - this.recordingPlan.participants[participantId] = { - participantId, - label: detail.participant.label || participantId, - tracks: {}, - }; - } - const participantPlan = this.recordingPlan.participants[participantId]; - const trackKey = `${detail.trackType || 'audio'}:${typeof detail.channelIndex === 'number' ? detail.channelIndex : 0}`; - if (!participantPlan.tracks[trackKey]) { - participantPlan.tracks[trackKey] = { - trackType: detail.trackType || 'audio', - channelIndex: typeof detail.channelIndex === 'number' ? detail.channelIndex : 0, - segments: [], - totalBytes: 0, - sequence: 0, - }; - } - const track = participantPlan.tracks[trackKey]; - const bytes = detail.data?.size || 0; - track.sequence += 1; - track.totalBytes += bytes; - const timecodeMs = this.recordStartedAt ? Date.now() - this.recordStartedAt : 0; - const segment = { - sequence: track.sequence, - bytes, - receivedAt: Date.now(), - timecodeMs, - }; - if (track.segments.length > 48) { - track.segments.shift(); - } - track.segments.push(segment); - } - - summariseRecordingFiles(filesMap) { - if (!filesMap || typeof filesMap.forEach !== 'function') { - return []; - } - const summaries = []; - filesMap.forEach((meta) => { - if (!meta) { - return; - } - summaries.push({ - participant: meta.participant?.uuid || null, - label: meta.participant?.label || null, - trackType: meta.trackType, - channelIndex: meta.channelIndex, - filename: meta.filename, - mimeType: meta.mimeType, - size: meta.size, - durationSeconds: meta.durationSeconds, - }); - }); - return summaries; - } - - logRecordingEvent(type, data = {}) { - if (!type) { - return; - } - if (!this.recordingPlan) { - this.recordingPlan = { - sessionId: createRecordingSessionId(), - events: [], - }; - } - if (!Array.isArray(this.recordingPlan.events)) { - this.recordingPlan.events = []; - } - const timestamp = Date.now(); - const timecodeMs = this.recordStartedAt ? Math.max(0, timestamp - this.recordStartedAt) : 0; - this.recordingPlan.events.push({ - type, - timestamp, - timecodeMs, - data, - }); - if (this.recordingPlan.events.length > 2000) { - this.recordingPlan.events.shift(); - } - } - - setRecordingStatus(text, state = 'idle') { - if (!this.recordingStatusNode) { - return; - } - this.recordingStatusNode.textContent = text; - this.recordingStatusNode.dataset.state = state; - } - - formatBitrate(value) { - if (!Number.isFinite(value) || value <= 0) { - return null; - } - if (value >= 1000) { - const megabits = value / 1000; - return `${megabits.toFixed(megabits >= 10 ? 0 : 1)} Mbps`; - } - return `${Math.round(value)} kbps`; - } - - formatDuration(seconds) { - if (!Number.isFinite(seconds) || seconds < 0) { - return '0:00'; - } - const totalSeconds = Math.floor(seconds); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const secs = totalSeconds % 60; - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } - - async handleRecordToggle() { - if (this.recording) { - this.showOutputsMessage('Wrapping up recording…'); - this.logRecordingEvent('record:stop:requested', { reason: 'host-toggle' }); - this.setRecordingStatus('Stopping recording…', 'stopping'); - try { - await this.recorder.stop(); - } catch (error) { - console.error('Failed to stop recorder cleanly', error); - this.setStatusMessage('Recording stop failed: ' + (error?.message || 'unknown error')); - } - return; - } - try { - let diskInfo = null; - if (STUDIO_DISK_FEATURE_FLAG && this.diskRecordingEnabled) { - diskInfo = await this.ensureDiskCaptureReadiness({ interactive: true }); - if (diskInfo && diskInfo.error) { - this.setStatusMessage(diskInfo.error.message || 'Disk folder not accessible.'); - return; - } - } - this.buildRecordingPlanContext({ diskInfo }); - this.logRecordingEvent('record:arm', { source: 'host-toggle' }); - this.updateRecordingPlanStatus('armed', { events: this.recordingPlan?.events || [] }); - this.setRecordingStatus('Arming recorders…', 'arming'); - await this.recorder.start({ - includeVideo: false, - includeLocal: false, - extraParticipants: this.getAdditionalRecordingParticipants(), - }); - } catch (error) { - console.error('Failed to start recorder', error); - this.setStatusMessage('Unable to start recording: ' + (error?.message || 'unknown error')); - this.updateHostMicUI(); - this.logRecordingEvent('record:error', { stage: 'start', message: error?.message || 'unknown error' }); - this.setRecordingStatus('Recording idle', 'error'); - this.updateRecordingPlanStatus('error', { error: error?.message || 'start failed', events: this.recordingPlan?.events || [] }); - this.setUploadProgressPending(false); - } - } - - async presentRecordings(filesMap) { - const files = filesMap || this.recorder.getFiles(); - if (!files || files.size === 0) { - this.showOutputsMessage('No media captured.'); - this.setUploadProgressPending(false); - return; - } - this.cleanupDownloadUrls(); - this.outputsContainer.dataset.mode = 'results'; - this.outputsContainer.dataset.hasTracks = ''; - this.outputsContainer.classList.remove('timeline-tracklist'); - this.outputsContainer.classList.add('timeline-results'); - this.outputsContainer.innerHTML = ''; - const uploadPromises = []; - this.setUploadProgressPending(false); - files.forEach((meta, key) => { - if (!meta?.blob) { - return; - } - const wrapper = createElement('div', 'timeline-entry'); - wrapper.dataset.key = key; - const label = `${meta.trackType.toUpperCase()} • ${meta.participant.label || meta.participant.uuid}`; - const downloadUrl = URL.createObjectURL(meta.blob); - this.activeDownloadUrls.push(downloadUrl); - const linkLabel = meta.mimeType === 'audio/wav' ? 'Download WAV' : 'Download'; - const link = createElement('a', 'marker-badge', { text: linkLabel, href: downloadUrl }); - const fallbackExtension = meta.mimeType?.split('/')?.[1] || 'webm'; - link.download = meta.filename || `${meta.participant.streamID || meta.participant.uuid}-${meta.trackType}.${fallbackExtension}`; - const header = createElement('div', 'timeline-entry-header'); - header.append(createElement('span', 'timeline-entry-label', { text: label }), link); - wrapper.append(header); - const metaSummary = this.describeTrackMeta(meta) || 'Metadata pending'; - const metaLine = createElement('div', 'upload-meta', { text: metaSummary }); - if (meta.packagingError) { - metaLine.textContent += metaSummary ? ' • fallback export' : 'Fallback export'; - } - wrapper.append(metaLine); - const statusContainer = createElement('div', 'upload-status'); - const driveLine = this.createServiceStatusLine('drive'); - const dropboxLine = this.createServiceStatusLine('dropbox'); - statusContainer.append(driveLine, dropboxLine); - wrapper.append(statusContainer); - this.outputsContainer.append(wrapper); - uploadPromises.push( - this.queueCloudUpload(meta, { - drive: driveLine, - dropbox: dropboxLine, - }), - ); - }); - if (uploadPromises.length) { - try { - await Promise.allSettled(uploadPromises); - } catch (error) { - console.warn('One or more uploads failed', error); - } - } - this.updateCloudFooter(); - } - - addMarker() { - const timestamp = this.recordStartedAt ? (Date.now() - this.recordStartedAt) / 1000 : 0; - const note = { - time: timestamp, - label: `Marker @ ${timestamp.toFixed(1)}s`, - }; - this.markers.push(note); - this.logRecordingEvent('marker', { label: note.label, timeSeconds: note.time }); - this.renderMarkers(); - } - - renderMarkers() { - this.markerLog.innerHTML = ''; - if (!this.markers.length) { - const empty = createElement('div', 'empty-state', { text: 'Tap “Add Marker” to drop cue points during recording.' }); - empty.dataset.empty = 'true'; - this.markerLog.append(empty); - return; - } - this.markers.forEach((marker, index) => { - const item = createElement('div', 'marker-item'); - item.append(createElement('span', '', { text: marker.label })); - item.append(createElement('span', 'marker-badge', { text: `#${index + 1}` })); - this.markerLog.append(item); - }); - } - - startRosterLoop() { - if (this.rosterTimer) { - clearInterval(this.rosterTimer); - } - this.rosterTimer = setInterval(() => this.refreshRoster(), ROSTER_REFRESH_MS); - } - - refreshRoster() { - if (!this.session) { - return; - } - this.updateRoomIndicator(); - const baseParticipants = collectParticipants(this.session); - const participants = [...baseParticipants]; - this.virtualParticipants.forEach((participant) => { - if (participant) { - participants.push(participant); - } - }); - const activeIds = new Set(); - - participants.forEach((participant) => { - activeIds.add(participant.uuid); - this.captureParticipantMetrics(participant); - const existing = this.rosterItems.get(participant.uuid); - if (existing) { - this.updateRosterItem(existing, participant); - } else { - const item = this.createRosterItem(participant); - this.rosterItems.set(participant.uuid, item); - this.rosterList.append(item); - } - }); - - Array.from(this.rosterItems.keys()).forEach((uuid) => { - if (!activeIds.has(uuid)) { - const node = this.rosterItems.get(uuid); - if (node?.parentNode) { - node.parentNode.removeChild(node); - } - this.rosterItems.delete(uuid); - this.meterValues.delete(uuid); - this.teardownDriveControl(uuid); - if (this.remoteOverlay && this.remoteOverlay.dataset.activeUuid === uuid) { - this.closeRemoteOverlay(); - } - } - }); - } - - createRosterItem(participant) { - const item = createElement('div', 'roster-item'); - item.dataset.uuid = participant.uuid; - item.dataset.status = participant.status || 'connecting'; - if (participant.role) { - item.dataset.role = participant.role; - } - - const meta = createElement('div', 'roster-meta'); - meta.append(createElement('div', 'roster-name', { text: participant.label })); - const idText = participant.streamID ? `Stream: ${participant.streamID}` : 'Awaiting stream'; - meta.append(createElement('div', 'roster-id', { text: idText })); - const descriptorText = this.describeParticipantRole(participant); - if (descriptorText) { - meta.append(createElement('div', 'roster-role', { text: descriptorText })); - } - - const meter = createElement('div', 'meter-bar', { 'data-meter': participant.uuid }); - meter.append(createElement('div', 'meter-bar-fill')); - - item.append(meta, meter); - - const actions = createElement('div', 'roster-actions'); - const actionRow = createElement('div', 'roster-action-row'); - let hasActions = false; - if (participant.role !== 'host-mic') { - const controlButton = createElement('button', 'roster-action-button', { - type: 'button', - text: 'Remote Controls', - }); - controlButton.addEventListener('click', () => this.openRemoteControls(participant.uuid)); - actionRow.append(controlButton); - hasActions = true; - } - const driveControls = this.createDriveControl(participant); - if (driveControls) { - actionRow.append(driveControls.button); - hasActions = true; - } - if (hasActions) { - actions.append(actionRow); - if (driveControls?.status) { - actions.append(driveControls.status); - } - item.append(actions); - } - - this.updateRosterItem(item, participant); - return item; - } - - updateRosterItem(item, participant) { - item.dataset.status = participant.status || 'connecting'; - const name = item.querySelector('.roster-name'); - if (name) { - name.textContent = participant.label; - } - const id = item.querySelector('.roster-id'); - if (id) { - id.textContent = participant.streamID ? `Stream: ${participant.streamID}` : 'Awaiting stream'; - } - item.dataset.role = participant.role || ''; - const descriptor = item.querySelector('.roster-role'); - if (descriptor) { - const descriptorText = this.describeParticipantRole(participant); - descriptor.textContent = descriptorText || ''; - descriptor.style.display = descriptorText ? '' : 'none'; - } - this.applyMeterValue(participant.uuid, participant.audioLevel || 0); - this.updateDriveActionAvailability(participant.uuid); - } - - createDriveControl(participant) { - if (!participant || participant.role === 'host-mic' || !participant.uuid) { - return null; - } - if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { - return null; - } - const button = createElement('button', 'roster-action-button roster-action-button--drive', { - type: 'button', - text: 'Drive Upload', - }); - button.dataset.uuid = participant.uuid; - button.addEventListener('click', () => this.handleDriveRecordToggle(participant.uuid)); - - const status = createElement('div', 'roster-drive-status', { text: DRIVE_STATUS_MESSAGES.idle }); - status.dataset.state = 'idle'; - status.dataset.uuid = participant.uuid; - - this.rosterDriveButtons.set(participant.uuid, button); - this.rosterDriveStatuses.set(participant.uuid, status); - this.updateDriveActionAvailability(participant.uuid); - this.applyDriveSnapshot(participant.uuid); - - return { button, status }; - } - - teardownDriveControl(uuid) { - if (!uuid) { - return; - } - if (this.driveStatusResetTimers.has(uuid)) { - clearTimeout(this.driveStatusResetTimers.get(uuid)); - this.driveStatusResetTimers.delete(uuid); - } - this.rosterDriveButtons.delete(uuid); - this.rosterDriveStatuses.delete(uuid); - this.driveProgressSnapshots.delete(uuid); - } - - findLegacyDriveButton(uuid) { - if (!uuid || typeof document === 'undefined') { - return null; - } - return document.querySelector('[data-action-type="recorder-google-drive-remote"][data--u-u-i-d="' + uuid + '"]'); - } - - canTriggerDriveUpload() { - if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { - return false; - } - return Boolean(this.cloud?.hasDriveAccess()); - } - - async handleDriveRecordToggle(uuid) { - if (!uuid) { - return; - } - const button = this.rosterDriveButtons.get(uuid); - if (!button) { - return; - } - if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { - this.setRosterDriveStatus(uuid, 'error', 'Drive controls unavailable in this build.'); - return; - } - const legacyButton = this.findLegacyDriveButton(uuid); - if (!legacyButton) { - this.setRosterDriveStatus(uuid, 'pending', 'Guest controls preparing…'); - this.updateDriveActionAvailability(uuid); - return; - } - const isActive = legacyButton.classList?.contains('pressed'); - if (!isActive && !this.canTriggerDriveUpload()) { - this.setRosterDriveStatus(uuid, 'error', 'Link Google Drive above to enable uploads.'); - this.updateDriveActionAvailability(uuid); - return; - } - button.dataset.pending = 'true'; - button.disabled = true; - try { - if (isActive) { - await window.requestGoogleDriveRecord(legacyButton, false); - this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); - } else { - this.setRosterDriveStatus(uuid, 'pending', 'Requesting Drive upload…'); - await window.requestGoogleDriveRecord(legacyButton); - } - } catch (error) { - const message = error?.message || 'Drive request cancelled'; - this.setRosterDriveStatus(uuid, 'error', message); - } finally { - button.dataset.pending = 'false'; - this.updateDriveActionAvailability(uuid); - } - } - - updateDriveActionAvailability(uuid) { - const button = this.rosterDriveButtons.get(uuid); - if (!button) { - return; - } - const hasRequestApi = typeof window !== 'undefined' && typeof window.requestGoogleDriveRecord === 'function'; - const legacyButton = this.findLegacyDriveButton(uuid); - const hasLegacyControl = Boolean(legacyButton); - const isActive = Boolean(legacyButton?.classList?.contains('pressed')); - const pending = button.dataset.pending === 'true'; - - let disabled = pending || !hasRequestApi; - let title = ''; - - if (!hasRequestApi) { - title = 'Drive controls are not available in this build.'; - } else if (!hasLegacyControl) { - title = 'Guest controls are still initialising.'; - disabled = true; - } else if (isActive) { - title = 'Stop this guest’s Drive upload.'; - disabled = pending; - } else if (!this.canTriggerDriveUpload()) { - title = 'Link Google Drive above to enable uploads.'; - disabled = true; - } else { - title = 'Ask this guest to upload to Drive.'; - disabled = pending; - } - - button.disabled = disabled; - button.textContent = isActive ? 'Stop Drive Upload' : 'Drive Upload'; - button.dataset.state = isActive ? 'active' : 'idle'; - if (title) { - button.title = title; - } - } - - updateAllDriveActions() { - this.rosterDriveButtons.forEach((_, uuid) => this.updateDriveActionAvailability(uuid)); - } - - setRosterDriveStatus(uuid, state = 'idle', text) { - const node = this.rosterDriveStatuses.get(uuid); - if (!node) { - return; - } - if (this.driveStatusResetTimers.has(uuid)) { - clearTimeout(this.driveStatusResetTimers.get(uuid)); - this.driveStatusResetTimers.delete(uuid); - } - const label = text || DRIVE_STATUS_MESSAGES[state] || DRIVE_STATUS_MESSAGES.idle; - node.dataset.state = state; - node.textContent = label; - if (state === 'done') { - const timer = setTimeout(() => { - this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); - this.driveStatusResetTimers.delete(uuid); - }, DRIVE_STATUS_RESET_MS); - this.driveStatusResetTimers.set(uuid, timer); - } - } - - applyDriveSnapshot(uuid) { - const snapshot = this.driveProgressSnapshots.get(uuid); - if (!snapshot) { - return; - } - this.setRosterDriveStatusFromSnapshot(uuid, snapshot); - } - - setRosterDriveStatusFromSnapshot(uuid, gdrive) { - if (!gdrive) { - this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); - return; - } - if (gdrive.state === 2) { - this.setRosterDriveStatus(uuid, 'done', DRIVE_STATUS_MESSAGES.done); - return; - } - if (typeof gdrive.rec === 'number' && gdrive.rec > 0) { - const percent = Math.min(100, Math.round((gdrive.up / Math.max(1, gdrive.rec)) * 100)); - this.setRosterDriveStatus(uuid, 'uploading', `Drive upload ${percent}%`); - } else { - this.setRosterDriveStatus(uuid, 'pending', DRIVE_STATUS_MESSAGES.pending); - } - } - - handleDriveProgressEvent(event) { - const detail = event?.detail; - if (!detail || !detail.UUID) { - return; - } - const { UUID: uuid, gdrive } = detail; - this.driveProgressSnapshots.set(uuid, gdrive || null); - if (!this.rosterDriveStatuses.has(uuid)) { - return; - } - this.setRosterDriveStatusFromSnapshot(uuid, gdrive || null); - this.updateDriveActionAvailability(uuid); - } - - ensureRemoteOverlay() { - if (this.remoteOverlay && this.remoteOverlayContent) { - return this.remoteOverlay; - } - const overlay = createElement('div', 'remote-overlay'); - overlay.dataset.podcastOverlay = 'true'; - overlay.dataset.visible = 'false'; - - const panel = createElement('div', 'remote-overlay__panel'); - const header = createElement('div', 'remote-overlay__header'); - const title = createElement('h3', 'remote-overlay__title', { text: 'Remote controls' }); - const closeButton = createElement('button', 'remote-overlay__close', { type: 'button', text: 'Close' }); - closeButton.addEventListener('click', () => this.closeRemoteOverlay()); - header.append(title, closeButton); - - const body = createElement('div', 'remote-overlay__body'); - panel.append(header, body); - overlay.append(panel); - - overlay.addEventListener('click', (event) => { - if (event.target === overlay) { - this.closeRemoteOverlay(); - } - }); - - document.body.appendChild(overlay); - this.remoteOverlay = overlay; - this.remoteOverlayContent = body; - return overlay; - } - - restoreRemoteControls() { - const state = this.remoteControlState; - if (!state || !state.element) { - if (this.remoteOverlay) { - delete this.remoteOverlay.dataset.activeUuid; - } - return; - } - const { element, placeholder, wrapper } = state; - try { - if (wrapper && wrapper.parentNode) { - wrapper.parentNode.removeChild(wrapper); - } - } catch (error) { - console.warn('Failed to remove remote controls wrapper', error); - } - if (placeholder && placeholder.parentNode) { - try { - placeholder.parentNode.insertBefore(element, placeholder); - placeholder.parentNode.removeChild(placeholder); - } catch (error) { - console.warn('Failed to restore remote controls container', error); - } - } - this.remoteControlState = { - activeUuid: null, - element: null, - placeholder: null, - wrapper: null, - }; - if (this.remoteOverlay) { - delete this.remoteOverlay.dataset.activeUuid; - } - } - - openRemoteControls(uuid) { - if (!uuid) { - return; - } - if (this.remoteControlState?.activeUuid && this.remoteControlState.activeUuid !== uuid) { - this.restoreRemoteControls(); - } - const overlay = this.ensureRemoteOverlay(); - const body = this.remoteOverlayContent; - if (!overlay || !body) { - return; - } - body.innerHTML = ''; - - const rosterNode = this.rosterItems.get(uuid); - let label = ''; - if (rosterNode) { - const nameNode = rosterNode.querySelector('.roster-name'); - label = nameNode ? nameNode.textContent : ''; - } - const headerTitle = overlay.querySelector('.remote-overlay__title'); - if (headerTitle) { - headerTitle.textContent = label ? `Remote controls • ${label}` : 'Remote controls'; - } - - const existingState = this.remoteControlState || {}; - if (existingState.activeUuid && existingState.activeUuid === uuid && existingState.wrapper) { - body.append(existingState.wrapper); - overlay.dataset.visible = 'true'; - overlay.dataset.activeUuid = uuid; - return; - } - - const source = document.getElementById(`container_${uuid}`); - if (!source) { - body.append( - createElement('div', 'remote-overlay__empty', { - text: 'Legacy director controls are still loading. Try again once the guest is fully connected.', - }), - ); - overlay.dataset.visible = 'true'; - overlay.dataset.activeUuid = uuid; - return; - } - - const placeholder = document.createElement('div'); - placeholder.dataset.podcastPlaceholder = 'remote-controls'; - source.parentNode?.insertBefore(placeholder, source); - - source.classList.remove('hidden'); - - const wrapper = createElement('div', 'remote-overlay__legacy'); - wrapper.dataset.uuid = uuid; - wrapper.append(source); - body.append(wrapper); - - this.remoteControlState = { - activeUuid: uuid, - element: source, - placeholder, - wrapper, - }; - - overlay.dataset.visible = 'true'; - overlay.dataset.activeUuid = uuid; - } - - closeRemoteOverlay() { - if (!this.remoteOverlay) { - return; - } - this.restoreRemoteControls(); - this.remoteOverlay.dataset.visible = 'false'; - if (this.remoteOverlayContent) { - this.remoteOverlayContent.innerHTML = ''; - } - } - - describeParticipantRole(participant) { - if (!participant) { - return ''; - } - if (participant.role === 'host-mic') { - return 'Local recording input'; - } - return ''; - } - - applyMeterValue(uuid, value) { - const percent = Math.min(100, Math.max(0, value)); - this.meterValues.set(uuid, percent); - const meter = this.rosterList.querySelector(`[data-meter="${uuid}"] .meter-bar-fill`); - if (meter) { - meter.style.width = `${percent}%`; - } - } - - updateMeterFromBus(payload) { - if (!payload?.uuid) { - return; - } - const peak = payload.peak || 0; - const level = Math.min(100, Math.round(peak * 120)); - this.applyMeterValue(payload.uuid, level); - this.updateTrackLevelVisual(payload.uuid, level); - } - - updateCloudFooter() { - if (this.driveStatusNode) { - const driveText = this.cloud?.hasDriveAccess() - ? 'Google Drive linked' - : 'Drive link pending'; - this.driveStatusNode.textContent = driveText; - } - if (this.dropboxStatusNode) { - const dropboxText = this.cloud?.hasDropboxAccess() - ? 'Dropbox linked' - : 'Dropbox link pending'; - this.dropboxStatusNode.textContent = dropboxText; - } - this.updateCloudLinkUI(); - this.updateReadinessSummary(); - } - - updateReadinessSummary() { - if (this.cloudSummaryNode) { - const state = readCloudLinkStatus(); - const driveActive = Boolean(this.cloud?.hasDriveAccess()); - const driveCached = isCloudLinkFresh(state.drive); - const dropboxActive = Boolean(this.cloud?.hasDropboxAccess()); - const dropboxCached = isCloudLinkFresh(state.dropbox); - const driveStatus = driveActive - ? `Drive ready${state.drive?.linkedAt ? ` (${formatRelativeTime(state.drive.linkedAt) || 'just now'})` : ''}` - : driveCached - ? `Drive linked earlier (${formatRelativeTime(state.drive.linkedAt) || 'recently'}). Re-link to enable backups.` - : 'Drive not linked'; - const dropboxStatus = dropboxActive - ? `Dropbox ready${state.dropbox?.linkedAt ? ` (${formatRelativeTime(state.dropbox.linkedAt) || 'just now'})` : ''}` - : dropboxCached - ? `Dropbox linked earlier (${formatRelativeTime(state.dropbox.linkedAt) || 'recently'}). Re-link to enable backups.` - : 'Dropbox not linked'; - this.cloudSummaryNode.textContent = `Cloud uploads: ${driveStatus} • ${dropboxStatus}`; - this.cloudSummaryNode.dataset.state = driveActive || dropboxActive ? 'ready' : dropboxCached || driveCached ? 'error' : 'pending'; - } - if (this.diskSummaryNode) { - if (!STUDIO_DISK_FEATURE_FLAG) { - this.diskSummaryNode.textContent = 'Disk capture disabled via ?studioiso=0'; - this.diskSummaryNode.dataset.state = 'disabled'; - return; - } - if (typeof window.showDirectoryPicker !== 'function') { - this.diskSummaryNode.textContent = 'Disk capture: browser not supported'; - this.diskSummaryNode.dataset.state = 'disabled'; - return; - } - const diskState = readDiskRecordingState(); - if (diskState.lastError) { - this.diskSummaryNode.textContent = `Disk capture: ${diskState.lastError}`; - this.diskSummaryNode.dataset.state = 'error'; - return; - } - if (!diskState.folderName) { - this.diskSummaryNode.textContent = 'Disk capture: folder not selected'; - this.diskSummaryNode.dataset.state = 'pending'; - return; - } - if (!diskState.enabled) { - this.diskSummaryNode.textContent = `Disk capture: ${diskState.folderName} (toggle off)`; - this.diskSummaryNode.dataset.state = 'idle'; - return; - } - const verified = diskState.lastVerifiedAt ? `checked ${formatRelativeTime(diskState.lastVerifiedAt)}` : 'awaiting verification'; - this.diskSummaryNode.textContent = `Disk capture: ${diskState.folderName} (${verified})`; - this.diskSummaryNode.dataset.state = 'ready'; - } - } - - formatFileSize(bytes) { - if (!bytes && bytes !== 0) { - return ''; - } - const thresh = 1024; - if (bytes < thresh) { - return `${bytes} B`; - } - const units = ['KB', 'MB', 'GB', 'TB']; - let unitIndex = -1; - let value = bytes; - do { - value /= thresh; - unitIndex += 1; - } while (value >= thresh && unitIndex < units.length - 1); - return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; - } - - describeTrackMeta(meta) { - const parts = []; - if (meta?.mimeType) { - parts.push(meta.mimeType.toUpperCase()); - } - if (meta?.size) { - parts.push(this.formatFileSize(meta.size)); - } - if (meta?.durationSeconds) { - parts.push(`${meta.durationSeconds.toFixed(1)}s`); - } - return parts.join(' • '); - } - - describeService(service) { - if (service === 'drive') { - return 'Drive'; - } - if (service === 'dropbox') { - return 'Dropbox'; - } - return service || 'Service'; - } - - createServiceStatusLine(service) { - const line = createElement('div', 'upload-status-line'); - line.dataset.service = service; - const ready = - service === 'drive' - ? this.cloud?.hasDriveAccess() - : service === 'dropbox' - ? this.cloud?.hasDropboxAccess() - : false; - const hint = ready ? 'ready' : 'link to upload'; - line.textContent = `${this.describeService(service)}: ${hint}`; - return line; - } - - setUploadProgressPending(pending) { - ['drive', 'dropbox'].forEach((service) => { - const node = this.cloudProgressNodes?.[service]; - if (!node) { - return; - } - if (pending) { - node.dataset.state = 'pending'; - node.textContent = `${this.describeService(service)} uploads pending (recording in progress)`; - } else if (!this.uploadTrackers?.[service]?.size) { - node.dataset.state = 'idle'; - node.textContent = `${this.describeService(service)} uploads idle`; - } - }); - } - - registerUploadTask(service, meta) { - if (!service || !this.uploadTrackers?.[service]) { - return null; - } - const tracker = this.uploadTrackers[service]; - const key = `${service}-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const bytesTotal = meta?.blob?.size || 0; - tracker.set(key, { - key, - label: meta?.participant?.label || meta?.filename || 'Track', - bytesUploaded: 0, - bytesTotal, - status: 'pending', - startedAt: Date.now(), - }); - this.refreshUploadProgress(service); - return key; - } - - updateUploadTask(service, key, { uploaded, total, status } = {}) { - if (!service || !key || !this.uploadTrackers?.[service]) { - return; - } - const tracker = this.uploadTrackers[service]; - const entry = tracker.get(key); - if (!entry) { - return; - } - if (typeof uploaded === 'number') { - entry.bytesUploaded = uploaded; - } - if (typeof total === 'number' && total >= 0) { - entry.bytesTotal = total; - } - if (status) { - entry.status = status; - } - this.refreshUploadProgress(service); - } - - finalizeUploadTask(service, key, status = 'uploaded') { - if (!service || !key || !this.uploadTrackers?.[service]) { - return; - } - const tracker = this.uploadTrackers[service]; - const entry = tracker.get(key); - if (!entry) { - return; - } - entry.status = status; - if (!entry.bytesTotal) { - entry.bytesTotal = entry.bytesUploaded; - } - tracker.set(key, entry); - this.refreshUploadProgress(service); - const ttl = status === 'error' ? UPLOAD_TRACKER_COOLDOWN_MS * 2 : UPLOAD_TRACKER_COOLDOWN_MS; - setTimeout(() => { - const current = tracker.get(key); - if (current && current.status === status) { - tracker.delete(key); - this.refreshUploadProgress(service); - } - }, ttl); - } - - refreshUploadProgress(service) { - const node = this.cloudProgressNodes?.[service]; - const tracker = this.uploadTrackers?.[service]; - if (!node || !tracker) { - return; - } - if (!tracker.size) { - node.textContent = `${this.describeService(service)} uploads idle`; - node.dataset.state = 'idle'; - return; - } - const entries = Array.from(tracker.values()); - const errors = entries.filter((entry) => entry.status === 'error'); - const active = entries.filter((entry) => entry.status === 'pending' || entry.status === 'uploading'); - const completed = entries.filter((entry) => entry.status === 'uploaded'); - const skipped = entries.filter((entry) => entry.status === 'skipped'); - const uploadedBytes = entries.reduce((total, entry) => total + Math.min(entry.bytesUploaded || 0, entry.bytesTotal || entry.bytesUploaded || 0), 0); - const totalBytes = entries.reduce((total, entry) => total + (entry.bytesTotal || entry.bytesUploaded || 0), 0); - const percentage = totalBytes ? Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)) : 0; - if (errors.length) { - node.textContent = `${this.describeService(service)} upload error (${errors.length})`; - node.dataset.state = 'error'; - return; - } - if (active.length) { - node.textContent = `${this.describeService(service)} uploading ${active.length} file${active.length === 1 ? '' : 's'} • ${percentage}%`; - node.dataset.state = 'uploading'; - return; - } - if (completed.length || skipped.length) { - node.textContent = `${this.describeService(service)} uploads complete`; - node.dataset.state = 'complete'; - return; - } - node.textContent = `${this.describeService(service)} uploads idle`; - node.dataset.state = 'idle'; - } - - applyUploadResult(element, result) { - if (!element || !result) { - return; - } - const label = this.describeService(result.service || element.dataset.service); - element.dataset.status = result.status || 'unknown'; - if (result.status === 'uploaded') { - const sizeText = result.bytes ? ` (${this.formatFileSize(result.bytes)})` : ''; - element.textContent = `${label}: uploaded${sizeText}`; - } else if (result.status === 'skipped') { - element.textContent = `${label}: ${result.reason || 'skipped'}`; - } else if (result.status === 'error') { - const message = result.error?.message || result.error?.toString() || 'failed'; - element.textContent = `${label}: ${message}`; - element.dataset.status = 'error'; - } else { - element.textContent = `${label}: ${result.status || 'unknown'}`; - } - } - - enqueuePendingDriveUpload(meta, driveElement) { - if (!meta || !meta.blob) { - return; - } - const existing = this.pendingDriveUploads.find((entry) => entry.meta === meta); - if (existing) { - if (driveElement) { - existing.driveElement = driveElement; - } - return; - } - this.pendingDriveUploads.push({ - meta, - driveElement: driveElement || null, - }); - if (driveElement) { - driveElement.dataset.status = 'pending'; - driveElement.textContent = `${this.describeService('drive')}: waiting for link…`; - } - } - - async flushPendingDriveUploads() { - if (!this.pendingDriveUploads.length || !this.cloud?.hasDriveAccess()) { - return; - } - const pending = [...this.pendingDriveUploads]; - this.pendingDriveUploads = []; - for (const entry of pending) { - try { - await this.queueCloudUpload( - entry.meta, - { - drive: entry.driveElement, - }, - { driveOnly: true }, - ); - } catch (error) { - console.warn('Deferred Drive upload failed', error); - } - } - } - - async queueCloudUpload(meta, serviceElements = {}, options = {}) { - if (!this.cloud || !meta?.blob) { - if (serviceElements?.drive) { - serviceElements.drive.textContent = 'Drive: unavailable'; - } - if (serviceElements?.dropbox) { - serviceElements.dropbox.textContent = 'Dropbox: unavailable'; - } - return; - } - - const driveOnly = options.driveOnly === true; - - let driveClient = null; - let driveReady = false; - try { - driveClient = this.cloud.ensureDriveClient(); - driveReady = Boolean(this.cloud?.hasDriveAccess()); - } catch (error) { - console.warn('Drive client unavailable; continuing without Drive uploads', error); - } - let canDropbox = !driveOnly && Boolean(this.cloud?.hasDropboxAccess()); - if (!driveOnly && !canDropbox) { - try { - const dropboxClient = await this.cloud.ensureDropboxClient(); - canDropbox = Boolean(dropboxClient); - } catch (error) { - console.warn('Dropbox client unavailable; continuing without Dropbox uploads', error); - } - } - - if (serviceElements?.drive) { - const driveHint = driveReady ? 'preparing upload…' : 'link to upload'; - serviceElements.drive.textContent = `${this.describeService('drive')}: ${driveHint}`; - serviceElements.drive.dataset.status = driveReady ? 'pending' : 'idle'; - } - if (!driveOnly && serviceElements?.dropbox) { - serviceElements.dropbox.textContent = `${this.describeService('dropbox')}: ${canDropbox ? 'preparing upload…' : 'link to upload'}`; - serviceElements.dropbox.dataset.status = canDropbox ? 'pending' : 'idle'; - } - - const uploadKeys = {}; - const allowDriveUpload = driveReady && Boolean(driveClient); - if (allowDriveUpload) { - uploadKeys.drive = this.registerUploadTask('drive', meta); - } else if (!driveOnly && driveClient) { - this.enqueuePendingDriveUpload(meta, serviceElements?.drive || null); - } - if (!driveOnly && canDropbox) { - uploadKeys.dropbox = this.registerUploadTask('dropbox', meta); - } - - try { - const results = await this.cloud.uploadBlob(meta.blob, { - filename: meta.filename, - drive: allowDriveUpload, - dropbox: !driveOnly && canDropbox, - onProgress: (progress) => { - if (!progress?.service) { - return; - } - const label = this.describeService(progress.service); - if (progress.service === 'drive' && serviceElements?.drive) { - serviceElements.drive.textContent = `${label}: ${progress.percentage || 0}%`; - if (uploadKeys.drive) { - this.updateUploadTask('drive', uploadKeys.drive, { - uploaded: progress.uploaded, - total: progress.total, - status: 'uploading', - }); - } - } else if (progress.service === 'dropbox' && serviceElements?.dropbox) { - serviceElements.dropbox.textContent = `${label}: ${progress.percentage || 0}%`; - if (uploadKeys.dropbox) { - this.updateUploadTask('dropbox', uploadKeys.dropbox, { - uploaded: progress.uploaded, - total: progress.total, - status: 'uploading', - }); - } - } - }, - signal: this.abortUploadsController?.signal, - }); - if (allowDriveUpload) { - this.applyUploadResult(serviceElements?.drive, results.drive); - } - if (!driveOnly) { - this.applyUploadResult(serviceElements?.dropbox, results.dropbox); - } - if (uploadKeys.drive) { - this.finalizeUploadTask('drive', uploadKeys.drive, results.drive?.status || 'unknown'); - } - if (uploadKeys.dropbox) { - this.finalizeUploadTask('dropbox', uploadKeys.dropbox, results.dropbox?.status || 'unknown'); - } - } catch (error) { - console.error('Cloud upload failed', error); - if (allowDriveUpload && serviceElements?.drive) { - serviceElements.drive.textContent = 'Drive: upload failed'; - serviceElements.drive.dataset.status = 'error'; - } - if (!driveOnly && serviceElements?.dropbox) { - serviceElements.dropbox.textContent = 'Dropbox: upload failed'; - serviceElements.dropbox.dataset.status = 'error'; - } - if (uploadKeys.drive) { - this.finalizeUploadTask('drive', uploadKeys.drive, 'error'); - } - if (uploadKeys.dropbox) { - this.finalizeUploadTask('dropbox', uploadKeys.dropbox, 'error'); - } - } finally { - this.updateCloudFooter(); - } - } - - cleanupDownloadUrls() { - if (!this.activeDownloadUrls || !this.activeDownloadUrls.length) { - return; - } - this.activeDownloadUrls.forEach((url) => { - try { - URL.revokeObjectURL(url); - } catch (error) { - console.warn('Failed to revoke object URL', error); - } - }); - this.activeDownloadUrls = []; - } - - dispose() { - if (this.rosterTimer) { - clearInterval(this.rosterTimer); - this.rosterTimer = null; - } - if (this.diskStateListener) { - window.removeEventListener(PODCAST_DISK_EVENT, this.diskStateListener); - this.diskStateListener = null; - } - if (this.cloudStateListener) { - window.removeEventListener(PODCAST_CLOUD_EVENT, this.cloudStateListener); - this.cloudStateListener = null; - } - if (this.boundDriveProgressHandler) { - window.removeEventListener(DRIVE_PROGRESS_EVENT, this.boundDriveProgressHandler); - this.boundDriveProgressHandler = null; - } - if (this.levelOff) { - this.levelOff(); - this.levelOff = null; - } - if (this.abortUploadsController) { - this.abortUploadsController.abort(); - this.abortUploadsController = null; - } - if (this.hostMic?.active || this.virtualParticipants.size) { - this.disableHostMic().catch((error) => { - console.warn('Failed to disable host microphone during dispose', error); - }); - } - this.restoreRemoteControls(); - if (this.chatModule) { - try { - if (this.chatModule.dataset) { - delete this.chatModule.dataset.podcastOverlay; - } - if (this.chatPlaceholder?.parentNode) { - this.chatPlaceholder.parentNode.insertBefore(this.chatModule, this.chatPlaceholder); - this.chatPlaceholder.parentNode.removeChild(this.chatPlaceholder); - } - const legacyHeader = this.chatModule.querySelector('.chat-header'); - if (legacyHeader) { - if (legacyHeader.dataset && Object.prototype.hasOwnProperty.call(legacyHeader.dataset, 'podcastDisplay')) { - legacyHeader.style.display = legacyHeader.dataset.podcastDisplay || ''; - delete legacyHeader.dataset.podcastDisplay; - } else { - legacyHeader.style.display = ''; - } - } - const legacyResizer = this.chatModule.querySelector('.resizer'); - if (legacyResizer) { - if (legacyResizer.dataset && Object.prototype.hasOwnProperty.call(legacyResizer.dataset, 'podcastDisplay')) { - legacyResizer.style.display = legacyResizer.dataset.podcastDisplay || ''; - delete legacyResizer.dataset.podcastDisplay; - } else { - legacyResizer.style.display = ''; - } - } - const popLink = this.chatModule.querySelector('#popOutChat'); - if (popLink) { - popLink.style.display = ''; - } - const closeLink = this.chatModule.querySelector('#closeChat'); - if (closeLink) { - closeLink.style.display = ''; - } - if (this.chatModule.style) { - this.chatModule.style.position = ''; - this.chatModule.style.right = ''; - this.chatModule.style.left = ''; - this.chatModule.style.bottom = ''; - this.chatModule.style.top = ''; - this.chatModule.style.zIndex = ''; - this.chatModule.style.maxWidth = ''; - this.chatModule.style.width = ''; - this.chatModule.style.height = ''; - this.chatModule.style.maxHeight = ''; - this.chatModule.style.overflow = ''; - this.chatModule.style.margin = ''; - } - this.chatModule.classList.add('hidden'); - } catch (error) { - console.warn('Failed to restore chat module', error); - } - this.chatModule = null; - this.chatPlaceholder = null; - } - this.chatPanel = null; - this.chatCollapseButton = null; - this.chatPopoutButton = null; - this.chatPopoutAnchor = null; - this.chatCollapsed = false; - this.chatCollapsedHint = null; - this.cleanupDownloadUrls(); - if (this.stopMeterBridge) { - this.stopMeterBridge(); - this.stopMeterBridge = null; - } - if (this.inviteCopyTimer) { - clearTimeout(this.inviteCopyTimer); - this.inviteCopyTimer = null; - } - if (this.remoteOverlay && this.remoteOverlay.parentNode) { - this.remoteOverlay.parentNode.removeChild(this.remoteOverlay); - } - this.remoteOverlay = null; - this.remoteOverlayContent = null; - this.rosterDriveButtons.clear(); - this.rosterDriveStatuses.clear(); - this.driveStatusResetTimers.forEach((timer) => clearTimeout(timer)); - this.driveStatusResetTimers.clear(); - } -} - -async function bootstrap() { - try { - const preflight = await ensureRoomSelection(); - if (preflight?.redirect) { - return; - } - const app = new PodcastStudioApp({ roomHint: preflight?.roomSlug }); - await app.init(); - window.podcastStudioApp = app; - } catch (error) { - console.error('Failed to initialise podcast studio', error); - } -} - -bootstrap(); +import { + waitForLegacySession, + levelBus, + LEVEL_EVENT, + MultiTrackRecorder, + CloudUploadCoordinator, + bridgeLegacyMeters, + monitorTrackLevel, +} from '../core/index.js'; + +const STUDIO_ROOT_ID = 'podcast-root'; +const ROSTER_REFRESH_MS = 1500; +const PREFLIGHT_STORAGE_KEY = 'podcastStudio.preflightState'; +const PREFLIGHT_CACHE_MS = 6 * 60 * 60 * 1000; +const PREFLIGHT_MIN_MANDATORY_MS = 5 * 60 * 1000; +const DROPBOX_GUIDE_URL = '/cloud.html#dropbox'; +const CLOUD_STATUS_STORAGE_KEY = 'podcastStudio.cloudStatus'; +const CLOUD_STATUS_STALE_MS = 30 * 60 * 1000; +const DISK_RECORDING_STORAGE_KEY = 'podcastStudio.diskRecordingState'; +const DISK_DB_NAME = 'podcastStudio.disk'; +const DISK_DB_STORE = 'handles'; +const PODCAST_CLOUD_EVENT = 'podcast-cloud-status'; +const PODCAST_DISK_EVENT = 'podcast-disk-state'; +const PODCAST_RECORD_PLAN_EVENT = 'podcast-record-plan'; +const PODCAST_RECORD_STATUS_EVENT = 'podcast-record-status'; +const UPLOAD_TRACKER_COOLDOWN_MS = 15000; +const DRIVE_PROGRESS_EVENT = 'vdoninja:gdrive-progress'; +const DRIVE_STATUS_RESET_MS = 8000; +const DRIVE_STATUS_MESSAGES = { + idle: 'Drive idle', + pending: 'Drive readying…', + uploading: 'Drive uploading…', + done: 'Drive upload complete', + error: 'Drive upload error', +}; +const STUDIO_DISK_FEATURE_FLAG = (() => { + let enabled = true; + if (typeof urlParams !== 'undefined' && urlParams) { + const hasParam = typeof urlParams.has === 'function' ? urlParams.has('studioiso') : false; + if (hasParam) { + const rawValue = typeof urlParams.get === 'function' ? urlParams.get('studioiso') : null; + const normalized = (rawValue || '1').toString().toLowerCase(); + enabled = !['0', 'false', 'off', 'no'].includes(normalized); + } + } + return enabled; +})(); + +function injectStylesheet() { + if (document.getElementById('podcast-studio-style')) { + return; + } + const link = document.createElement('link'); + link.id = 'podcast-studio-style'; + link.rel = 'stylesheet'; + link.href = new URL('./studio.css?v=4', import.meta.url).toString(); + document.head.appendChild(link); +} + +function createElement(tag, className, attrs = {}) { + const el = document.createElement(tag); + if (className) { + el.className = className; + } + Object.entries(attrs).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (key === 'text') { + el.textContent = value; + } else { + el.setAttribute(key, value); + } + }); + return el; +} + +function makeCollapsible(panel, title, storageKey = null) { + panel.dataset.collapsible = 'true'; + + // Add title h2 if provided and panel doesn't already have one + if (title && !panel.querySelector('h2')) { + const h2 = createElement('h2', '', { text: title }); + panel.insertBefore(h2, panel.firstChild); + } + + // Create toggle button (will be positioned absolute in top right via CSS) + const toggle = createElement('button', 'panel-collapse-toggle', { type: 'button', text: '−', title: 'Collapse section' }); + + // Load saved state + let collapsed = false; + if (storageKey) { + try { + collapsed = localStorage.getItem(storageKey) === 'true'; + } catch (e) {} + } + + const updateState = () => { + panel.dataset.collapsed = collapsed ? 'true' : 'false'; + toggle.textContent = collapsed ? '+' : '−'; + toggle.title = collapsed ? 'Expand section' : 'Collapse section'; + if (storageKey) { + try { + localStorage.setItem(storageKey, collapsed ? 'true' : 'false'); + } catch (e) {} + } + }; + + toggle.addEventListener('click', () => { + collapsed = !collapsed; + updateState(); + }); + + panel.appendChild(toggle); + updateState(); + + return { toggle }; +} + +function dispatchStudioEvent(name, detail = {}) { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') { + return; + } + try { + window.dispatchEvent(new CustomEvent(name, { detail })); + } catch (error) { + console.warn('Unable to dispatch studio event', name, error); + } +} + +const SPECTROGRAM_GRADIENT = [ + { stop: 0, color: [4, 5, 13] }, // floor + { stop: 0.25, color: [24, 60, 140] }, + { stop: 0.45, color: [47, 231, 163] }, // studio green accent + { stop: 0.7, color: [255, 153, 68] }, // warning orange + { stop: 1, color: [255, 255, 255] }, +]; + +const DEFAULT_SPECTROGRAM_OPTIONS = { + fps: 24, + pixelStep: 1, + decay: 0.008, + noiseFloor: 2, + gamma: 0.65, + frequencyExponent: 0.95, + lowFrequencyCutoff: 0.55, + lowFrequencyGain: 1.1, + lowFrequencySpread: 2, +}; + +function lerpColorChannel(start, end, ratio) { + return Math.round(start + (end - start) * ratio); +} + +function pickSpectrogramColor(value) { + const clamped = Math.min(1, Math.max(0, value)); + for (let i = 1; i < SPECTROGRAM_GRADIENT.length; i += 1) { + const prev = SPECTROGRAM_GRADIENT[i - 1]; + const next = SPECTROGRAM_GRADIENT[i]; + if (clamped <= next.stop) { + const span = next.stop - prev.stop || 1; + const ratio = (clamped - prev.stop) / span; + return [ + lerpColorChannel(prev.color[0], next.color[0], ratio), + lerpColorChannel(prev.color[1], next.color[1], ratio), + lerpColorChannel(prev.color[2], next.color[2], ratio), + ]; + } + } + const fallback = SPECTROGRAM_GRADIENT[SPECTROGRAM_GRADIENT.length - 1]; + return [...fallback.color]; +} + +class SpectrogramRenderer { + constructor(canvas, options = {}) { + this.canvas = canvas; + this.ctx = canvas?.getContext ? canvas.getContext('2d', { alpha: true }) : null; + this.options = { ...DEFAULT_SPECTROGRAM_OPTIONS, ...options }; + this.pixelStepBase = Math.max(1, this.options.pixelStep); + this.frameInterval = this.options.fps > 0 ? 1000 / this.options.fps : 0; + this.lastFrame = 0; + this.animationFrame = null; + this.resizeObserver = null; + this.resizeListener = null; + this.columnBuffer = null; + this.analyser = null; + this.frequencyData = null; + this.width = 0; + this.height = 0; + this.pixelStep = this.pixelStepBase; + this.noiseFloor = Math.max(0, this.options.noiseFloor); + this.gamma = Math.max(0.25, Math.min(1.5, this.options.gamma)); + this.frequencyExponent = Math.max(0.4, Math.min(2.4, this.options.frequencyExponent)); + this.lowFrequencyCutoff = Math.min(0.9, Math.max(0.05, this.options.lowFrequencyCutoff || 0.3)); + this.lowFrequencyGain = Math.max(1, this.options.lowFrequencyGain || 1.2); + this.lowFrequencySpread = Math.max(1, Math.round(this.options.lowFrequencySpread || 2)); + this.baseFillStyle = 'rgb(4, 5, 13)'; + this.boundResize = () => this.handleResize(); + this.renderLoop = (timestamp) => this.tick(timestamp); + if (this.ctx && this.canvas) { + this.ctx.imageSmoothingEnabled = false; + this.observeResize(); + this.handleResize(); + } + } + + observeResize() { + if (!this.canvas) { + return; + } + if (typeof ResizeObserver === 'function') { + this.resizeObserver = new ResizeObserver(this.boundResize); + this.resizeObserver.observe(this.canvas); + } else { + this.resizeListener = this.boundResize; + window.addEventListener('resize', this.resizeListener); + } + } + + handleResize() { + if (!this.canvas || !this.ctx) { + return; + } + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const nextWidth = Math.max(10, Math.floor(rect.width * dpr) || 10); + const nextHeight = Math.max(10, Math.floor(rect.height * dpr) || 10); + if (nextWidth === this.width && nextHeight === this.height) { + return; + } + this.width = nextWidth; + this.height = nextHeight; + this.pixelStep = Math.max(1, Math.round(this.pixelStepBase * dpr)); + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + this.columnBuffer = this.ctx.createImageData(this.pixelStep, this.height); + this.ctx.fillStyle = this.baseFillStyle; + this.ctx.fillRect(0, 0, this.width, this.height); + } + + ensureColumnBuffer() { + if (!this.ctx) { + return null; + } + if (!this.columnBuffer || this.columnBuffer.height !== this.height || this.columnBuffer.width !== this.pixelStep) { + this.columnBuffer = this.ctx.createImageData(this.pixelStep, this.height); + } + return this.columnBuffer; + } + + normalizeMagnitude(rawValue) { + if (!Number.isFinite(rawValue)) { + return 0; + } + const adjusted = Math.max(0, rawValue - this.noiseFloor); + const normalized = Math.min(1, adjusted / (255 - this.noiseFloor)); + return Math.pow(normalized, this.gamma); + } + + setAnalyser(analyser) { + if (this.analyser === analyser) { + return; + } + this.analyser = analyser || null; + this.frequencyData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : null; + if (this.analyser) { + this.startLoop(); + } else { + this.stopLoop(); + } + } + + startLoop() { + if (this.animationFrame || !this.analyser) { + return; + } + this.lastFrame = 0; + this.animationFrame = requestAnimationFrame(this.renderLoop); + } + + stopLoop() { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + tick(timestamp) { + if (!this.analyser || !this.frequencyData || !this.ctx || !this.canvas) { + this.stopLoop(); + return; + } + if (this.frameInterval && timestamp - this.lastFrame < this.frameInterval) { + this.animationFrame = requestAnimationFrame(this.renderLoop); + return; + } + this.lastFrame = timestamp; + this.drawColumn(); + this.animationFrame = requestAnimationFrame(this.renderLoop); + } + + drawColumn() { + if (!this.analyser || !this.frequencyData || !this.ctx) { + return; + } + try { + this.analyser.getByteFrequencyData(this.frequencyData); + } catch (error) { + console.warn('Spectrogram analyser unavailable', error); + this.frequencyData = null; + return; + } + const width = this.canvas.width; + const height = this.canvas.height; + const shift = Math.min(this.pixelStep, Math.max(1, width - 1)); + if (!width || !height || !shift) { + return; + } + this.ctx.drawImage(this.canvas, shift, 0, width - shift, height, 0, 0, width - shift, height); + const fadeStrength = Math.max(0, Math.min(1, this.options.decay)); + if (fadeStrength > 0 && width - shift > 0) { + this.ctx.save(); + this.ctx.globalAlpha = fadeStrength; + this.ctx.fillStyle = this.baseFillStyle; + this.ctx.fillRect(0, 0, width - shift, height); + this.ctx.restore(); + } + // clear the area reserved for the new samples + this.ctx.fillStyle = this.baseFillStyle; + this.ctx.fillRect(width - shift, 0, shift, height); + const column = this.ensureColumnBuffer(); + if (!column) { + return; + } + const bins = this.frequencyData.length; + for (let y = 0; y < height; y += 1) { + const ratio = 1 - y / height; + const curved = Math.pow(ratio, this.frequencyExponent); // slower exponent keeps low freqs visible + const baseIndex = Math.max(0, Math.min(bins - 1, Math.floor(curved * (bins - 1)))); + let accumulator = 0; + let samples = 0; + const isLowBand = curved <= this.lowFrequencyCutoff; + const spread = isLowBand ? this.lowFrequencySpread : 1; + for (let i = 0; i < spread; i += 1) { + const idx = Math.min(bins - 1, baseIndex + i); + accumulator += this.frequencyData[idx]; + samples += 1; + } + let magnitude = this.normalizeMagnitude(accumulator / Math.max(1, samples)); + if (isLowBand) { + magnitude = Math.min(1, magnitude * this.lowFrequencyGain); + } + const [r, g, b] = pickSpectrogramColor(magnitude); + const alpha = Math.round(35 + magnitude * 220); + for (let x = 0; x < shift; x += 1) { + const offset = (y * shift + x) * 4; + column.data[offset] = r; + column.data[offset + 1] = g; + column.data[offset + 2] = b; + column.data[offset + 3] = alpha; + } + } + this.ctx.putImageData(column, width - shift, 0); + } + + destroy() { + this.stopLoop(); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.resizeListener) { + window.removeEventListener('resize', this.resizeListener); + this.resizeListener = null; + } + this.analyser = null; + this.frequencyData = null; + if (this.ctx && this.canvas) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + } +} + +const ROOM_QUERY_KEYS = ['room', 'roomid', 'r']; +const DIRECTOR_QUERY_KEYS = ['director', 'dir']; +const ROOM_STATE_STORAGE_KEY = 'podcastStudio.lastRoom'; + +function sanitizeRoomSlug(value) { + if (!value) { + return ''; + } + const trimmed = String(value).trim(); + if (!trimmed) { + return ''; + } + try { + if (typeof window.sanitizeRoomName === 'function') { + return window.sanitizeRoomName(trimmed); + } + } catch (error) { + console.warn('sanitizeRoomName unavailable', error); + } + return trimmed.replace(/[^a-zA-Z0-9_\-]/g, '').slice(0, 64); +} + +function getRoomSlugFromParams(params = new URLSearchParams(window.location.search)) { + for (const key of DIRECTOR_QUERY_KEYS) { + if (params.has(key)) { + const slug = sanitizeRoomSlug(params.get(key)); + if (slug) { + return slug; + } + } + } + for (const key of ROOM_QUERY_KEYS) { + if (params.has(key)) { + const slug = sanitizeRoomSlug(params.get(key)); + if (slug) { + return slug; + } + } + } + return ''; +} + +function readStoredRoomState() { + try { + const raw = window.localStorage.getItem(ROOM_STATE_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { + room: typeof parsed.room === 'string' ? parsed.room : '', + password: typeof parsed.password === 'string' ? parsed.password : '', + }; + } + } catch (error) { + console.warn('Unable to read stored room state', error); + } + return {}; +} + +function persistStoredRoomState(state) { + try { + window.localStorage.setItem(ROOM_STATE_STORAGE_KEY, JSON.stringify(state || {})); + } catch (error) { + console.warn('Unable to store room state', error); + } +} + +function readPreflightState() { + try { + const raw = window.localStorage.getItem(PREFLIGHT_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch (error) { + console.warn('Unable to read preflight cache', error); + } + return {}; +} + +function writePreflightState(state) { + try { + window.localStorage.setItem(PREFLIGHT_STORAGE_KEY, JSON.stringify(state || {})); + } catch (error) { + console.warn('Unable to persist preflight cache', error); + } +} + +function isPreflightFresh(timestamp) { + if (!timestamp) { + return false; + } + return Date.now() - timestamp < PREFLIGHT_CACHE_MS; +} + +function formatRelativeTime(timestamp) { + if (!timestamp) { + return ''; + } + const deltaSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (deltaSeconds < 45) { + return 'just now'; + } + if (deltaSeconds < 90) { + return 'about a minute ago'; + } + if (deltaSeconds < 45 * 60) { + const minutes = Math.round(deltaSeconds / 60); + return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + } + if (deltaSeconds < 90 * 60) { + return 'about an hour ago'; + } + if (deltaSeconds < 36 * 3600) { + const hours = Math.round(deltaSeconds / 3600); + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + const days = Math.round(deltaSeconds / 86400); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +function createRecordingSessionId() { + try { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + } catch (error) { + console.warn('randomUUID unavailable', error); + } + return `rec-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function snapshotHighResClock() { + if (typeof performance === 'undefined' || typeof performance.now !== 'function') { + return null; + } + const now = performance.now(); + const origin = + typeof performance.timeOrigin === 'number' + ? performance.timeOrigin + : Date.now() - now; + return { + perfNow: now, + timeOrigin: origin, + wallClockMs: Math.round(origin + now), + }; +} + +function readCloudLinkStatus() { + try { + const raw = window.localStorage.getItem(CLOUD_STATUS_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + console.warn('Unable to read cloud link status', error); + return {}; + } +} + +function writeCloudLinkStatus(nextState) { + const snapshot = nextState || {}; + try { + window.localStorage.setItem(CLOUD_STATUS_STORAGE_KEY, JSON.stringify(snapshot)); + } catch (error) { + console.warn('Unable to persist cloud link status', error); + return; + } + dispatchStudioEvent(PODCAST_CLOUD_EVENT, { state: snapshot }); +} + +function isCloudLinkFresh(entry) { + if (!entry?.linkedAt) { + return false; + } + return Date.now() - entry.linkedAt < CLOUD_STATUS_STALE_MS; +} + +function markCloudLinked(service, details = {}) { + if (!service) { + return; + } + const state = readCloudLinkStatus(); + state[service] = { + linkedAt: Date.now(), + ...details, + }; + writeCloudLinkStatus(state); +} + +function markCloudUnlinked(service) { + if (!service) { + return; + } + const state = readCloudLinkStatus(); + if (state[service]) { + delete state[service]; + writeCloudLinkStatus(state); + } +} + +function readDiskRecordingState() { + try { + const raw = window.localStorage.getItem(DISK_RECORDING_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + console.warn('Unable to read disk recording state', error); + return {}; + } +} + +function isDiskRecordingEnabled() { + const state = readDiskRecordingState(); + return Boolean(state.folderName && state.enabled); +} + +function setDiskRecordingEnabled(enabled) { + const current = readDiskRecordingState(); + const next = { + ...current, + enabled: Boolean(enabled) && Boolean(current.folderName), + updatedAt: Date.now(), + }; + writeDiskRecordingState(next); + return next; +} + +function writeDiskRecordingState(state) { + const snapshot = state || {}; + try { + window.localStorage.setItem(DISK_RECORDING_STORAGE_KEY, JSON.stringify(snapshot)); + } catch (error) { + console.warn('Unable to persist disk recording state', error); + return; + } + dispatchStudioEvent(PODCAST_DISK_EVENT, { state: snapshot }); +} + +function openDiskHandleDatabase() { + return new Promise((resolve, reject) => { + if (!window.indexedDB) { + reject(new Error('IndexedDB unavailable')); + return; + } + const request = window.indexedDB.open(DISK_DB_NAME, 1); + request.onerror = () => reject(request.error || new Error('Unable to open disk handle database')); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(DISK_DB_STORE)) { + db.createObjectStore(DISK_DB_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + }); +} + +async function saveDiskDirectoryHandle(handle) { + if (!handle) { + return; + } + const db = await openDiskHandleDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(DISK_DB_STORE, 'readwrite'); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error || new Error('Unable to store disk handle')); + }; + tx.objectStore(DISK_DB_STORE).put(handle, 'primary'); + }); +} + +async function readDiskDirectoryHandle() { + const db = await openDiskHandleDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(DISK_DB_STORE, 'readonly'); + tx.oncomplete = () => { + db.close(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error || new Error('Unable to read disk handle')); + }; + const request = tx.objectStore(DISK_DB_STORE).get('primary'); + request.onsuccess = () => resolve(request.result || null); + }); +} + +async function verifyStoredDiskRecordingDirectory({ requestPermission = false } = {}) { + try { + const handle = await readDiskDirectoryHandle(); + if (!handle) { + return { ok: false, message: 'No folder selected yet.' }; + } + let permission = await handle.queryPermission({ mode: 'readwrite' }); + if (permission === 'prompt' && requestPermission) { + permission = await handle.requestPermission({ mode: 'readwrite' }); + } + if (permission !== 'granted') { + return { ok: false, message: 'Access to the selected folder was denied.' }; + } + const meta = readDiskRecordingState(); + writeDiskRecordingState({ + ...meta, + lastVerifiedAt: Date.now(), + folderName: meta.folderName || handle.name || 'Selected folder', + lastError: null, + }); + return { ok: true, folderName: meta.folderName || handle.name || 'Selected folder' }; + } catch (error) { + console.warn('Failed to verify disk folder', error); + const meta = readDiskRecordingState(); + writeDiskRecordingState({ + ...meta, + lastError: error?.message || 'Unable to verify folder access.', + }); + return { ok: false, message: error?.message || 'Unable to verify folder access.' }; + } +} + +async function chooseDiskRecordingDirectory() { + if (typeof window.showDirectoryPicker !== 'function') { + throw new Error('This browser does not support the file-system directory picker yet.'); + } + const handle = await window.showDirectoryPicker({ mode: 'readwrite' }); + if (!handle) { + throw new Error('Folder selection was cancelled.'); + } + await saveDiskDirectoryHandle(handle); + const meta = readDiskRecordingState(); + writeDiskRecordingState({ + ...meta, + folderName: handle.name || 'Recording folder', + lastVerifiedAt: Date.now(), + lastError: null, + }); + return { handle, folderName: handle.name || 'Recording folder' }; +} + +function buildRoomGate(defaults = {}) { + injectStylesheet(); + + const gate = createElement('div', '', { id: 'podcast-room-gate' }); + const panel = createElement('div', 'podcast-room-gate__panel'); + const title = createElement('h1', 'podcast-room-gate__title', { text: 'Start a Control Room' }); + const subtitle = createElement('p', 'podcast-room-gate__subtitle', { + text: 'Name your room to invite talent and capture their tracks. This matches the “&director=” link you share with guests.', + }); + + const form = createElement('form', 'podcast-room-gate__form'); + const roomLabel = createElement('label'); + roomLabel.append(createElement('span', '', { text: 'Room name' })); + const roomInput = createElement('input'); + roomInput.name = 'room'; + roomInput.placeholder = defaults.roomPlaceholder || 'podcast-hq'; + roomInput.title = 'This name becomes the room slug used in guest links.'; + roomInput.autocomplete = 'off'; + roomInput.autocapitalize = 'off'; + roomInput.spellcheck = false; + if (defaults.room) { + roomInput.value = defaults.room; + } + roomLabel.append(roomInput); + + const passwordLabel = createElement('label'); + passwordLabel.append(createElement('span', '', { text: 'Room password (optional)' })); + const passwordInput = createElement('input'); + passwordInput.name = 'password'; + passwordInput.placeholder = 'Leave blank to skip'; + passwordInput.title = 'Optional room password for guests and directors.'; + passwordInput.type = 'text'; + passwordInput.autocomplete = 'off'; + passwordInput.autocapitalize = 'off'; + passwordInput.spellcheck = false; + if (defaults.password) { + passwordInput.value = defaults.password; + } + passwordLabel.append(passwordInput); + + const errorNode = createElement('div', 'podcast-room-gate__error'); + + const actions = createElement('div', 'podcast-room-gate__actions'); + const cancelButton = createElement('button', 'podcast-room-gate__cancel', { + type: 'button', + text: 'Back to classic', + title: 'Return to the classic VDO.Ninja interface.', + }); + const submitButton = createElement('button', 'podcast-room-gate__submit', { + type: 'submit', + text: 'Enter studio', + title: 'Enter the podcast studio for this room.', + }); + actions.append(submitButton, cancelButton); + + form.append(roomLabel, passwordLabel, errorNode, actions); + panel.append(title, subtitle, form); + gate.append(panel); + document.body.append(gate); + + document.body.classList.remove('hidden'); + document.body.classList.add('podcast-studio-mode'); + + setTimeout(() => { + roomInput.focus(); + roomInput.select(); + }, 0); + + return { + gate, + form, + roomInput, + passwordInput, + errorNode, + submitButton, + cancelButton, + }; +} + +async function ensureRoomSelection() { + const params = new URLSearchParams(window.location.search); + const existing = getRoomSlugFromParams(params); + if (existing) { + injectStylesheet(); + const preflight = await runPreflightChecklist({ roomSlug: existing }); + if (preflight?.redirect) { + return preflight; + } + return { roomSlug: preflight?.roomSlug || existing }; + } + + const stored = readStoredRoomState(); + const gateElements = buildRoomGate(stored); + + return new Promise((resolve) => { + function redirectToClassic() { + const base = window.location.pathname; + gateElements.cancelButton.disabled = true; + gateElements.submitButton.disabled = true; + window.location.href = base || '/'; + resolve({ redirect: true }); + } + + function handleSubmit(event) { + event.preventDefault(); + const slug = sanitizeRoomSlug(gateElements.roomInput.value); + if (!slug) { + gateElements.errorNode.textContent = 'Room name is required.'; + return; + } + gateElements.errorNode.textContent = ''; + gateElements.submitButton.disabled = true; + gateElements.cancelButton.disabled = true; + + const updatedParams = new URLSearchParams(window.location.search); + updatedParams.set('studio', 'podcast'); + updatedParams.set('director', slug); + for (const key of DIRECTOR_QUERY_KEYS) { + if (key !== 'director') { + updatedParams.delete(key); + } + } + for (const key of ROOM_QUERY_KEYS) { + updatedParams.delete(key); + } + + const password = gateElements.passwordInput.value.trim(); + if (password) { + updatedParams.set('password', password); + } else { + updatedParams.delete('password'); + } + + persistStoredRoomState({ room: slug, password }); + window.location.search = updatedParams.toString(); + resolve({ redirect: true }); + } + + gateElements.form.addEventListener('submit', (event) => handleSubmit(event)); + gateElements.cancelButton.addEventListener('click', (event) => { + event.preventDefault(); + redirectToClassic(); + }); + // Rely on form submit for enter/return handling. + }); +} + +function describePreflightStatus(status) { + switch (status) { + case 'ready': + return 'Ready'; + case 'testing': + return 'Testing…'; + case 'error': + return 'Needs attention'; + default: + return 'Pending'; + } +} + +function createPreflightRow(label, description, options = {}) { + const { + initialStatus = 'pending', + actionLabel = 'Test', + showAction = true, + } = options; + const row = createElement('div', 'preflight-row'); + row.dataset.status = initialStatus; + + const info = createElement('div', 'preflight-row__info'); + const labelNode = createElement('div', 'preflight-row__label', { text: label }); + const descriptionNode = createElement('div', 'preflight-row__description', { text: description }); + const messageNode = createElement('div', 'preflight-row__message'); + info.append(labelNode, descriptionNode, messageNode); + + const controls = createElement('div', 'preflight-row__controls'); + const statusNode = createElement('span', 'preflight-row__status', { text: describePreflightStatus(initialStatus) }); + controls.append(statusNode); + + let actionButton = null; + if (showAction) { + actionButton = createElement('button', 'preflight-row__action', { type: 'button', text: actionLabel, title: `Run: ${label}` }); + controls.append(actionButton); + } + + row.append(info, controls); + return { + row, + info, + statusNode, + messageNode, + actionButton, + }; +} + +function setPreflightRowState(rowParts, status, message = '') { + if (!rowParts || !rowParts.row) { + return; + } + rowParts.row.dataset.status = status; + if (rowParts.statusNode) { + rowParts.statusNode.textContent = describePreflightStatus(status); + } + if (rowParts.messageNode) { + rowParts.messageNode.textContent = message || ''; + } + if (rowParts.actionButton) { + if (status === 'testing') { + rowParts.actionButton.disabled = true; + } else { + rowParts.actionButton.disabled = false; + } + if (status === 'ready') { + rowParts.actionButton.textContent = 'Retest'; + } else if (status === 'testing') { + rowParts.actionButton.textContent = 'Testing…'; + } else if (status === 'error') { + rowParts.actionButton.textContent = 'Retry'; + } else { + rowParts.actionButton.textContent = rowParts.actionButton.dataset.initialLabel || 'Test'; + } + } +} + +async function runPreflightChecklist({ roomSlug } = {}) { + const stored = readPreflightState(); + const now = Date.now(); + const micFresh = isPreflightFresh(stored.micSuccessAt); + const camFresh = isPreflightFresh(stored.cameraSuccessAt); + + // If the user just completed the preflight moments ago, allow immediate pass-through. + if (stored.completedAt && now - stored.completedAt < PREFLIGHT_MIN_MANDATORY_MS) { + document.body.classList.remove('hidden'); + document.body.classList.add('podcast-studio-mode'); + return { roomSlug, skipped: true }; + } + + const overlay = createElement('div', 'podcast-preflight-backdrop'); + overlay.dataset.podcastOverlay = 'true'; + // Inline styles ensure overlay is styled before external CSS loads + overlay.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.85);z-index:9999'; + const panel = createElement('div', 'podcast-preflight-panel'); + panel.style.cssText = 'background:#1a1d24;padding:32px;border-radius:12px;color:#fff;max-width:480px;width:90%'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'true'); + panel.setAttribute('aria-label', 'Podcast studio preflight checklist'); + + const heading = createElement('h2', 'preflight-title', { text: 'Check Your Setup' }); + const subtitleText = roomSlug + ? `Confirm your gear before directing room “${roomSlug}”.` + : 'Confirm your gear before directing a session.'; + const subtitle = createElement('p', 'preflight-subtitle', { text: subtitleText }); + + const checklist = createElement('div', 'preflight-list'); + const micRow = createPreflightRow('Microphone access', 'Verify your preferred mic is available and browser permission is granted.', { + initialStatus: micFresh ? 'ready' : 'pending', + actionLabel: micFresh ? 'Retest' : 'Test mic', + }); + if (micRow.actionButton) { + micRow.actionButton.dataset.initialLabel = micFresh ? 'Retest' : 'Test mic'; + } + if (micFresh) { + setPreflightRowState(micRow, 'ready', `Last checked ${formatRelativeTime(stored.micSuccessAt)}.`); + } + + const camRow = createPreflightRow('Camera access', 'Optional but useful if you plan to capture video.', { + initialStatus: camFresh ? 'ready' : 'pending', + actionLabel: camFresh ? 'Retest' : 'Test camera', + }); + if (camRow.actionButton) { + camRow.actionButton.dataset.initialLabel = camFresh ? 'Retest' : 'Test camera'; + } + if (camFresh) { + setPreflightRowState(camRow, 'ready', `Last checked ${formatRelativeTime(stored.cameraSuccessAt)}.`); + } + + const diskRow = createPreflightRow( + 'Local disk recording', + 'Select a destination folder for ISO files (optional but recommended).', + { + initialStatus: 'pending', + showAction: Boolean(window.showDirectoryPicker), + actionLabel: window.showDirectoryPicker ? 'Choose folder' : 'Unavailable', + } + ); + if (!window.showDirectoryPicker && diskRow.actionButton) { + diskRow.actionButton.disabled = true; + } + setPreflightRowState( + diskRow, + window.showDirectoryPicker ? 'pending' : 'error', + window.showDirectoryPicker ? 'No folder selected yet.' : 'Local disk recording requires the File System Access API (Chromium-based browsers).' + ); + + checklist.append(micRow.row, camRow.row, diskRow.row); + + const actions = createElement('div', 'preflight-actions'); + const continueButton = createElement('button', 'preflight-primary', { type: 'button', text: 'Enter Control Room', title: 'Enter the podcast studio.' }); + const skipButton = createElement('button', 'preflight-secondary', { type: 'button', text: 'Skip preflight', title: 'Skip these checks and enter the studio.' }); + actions.append(continueButton, skipButton); + + panel.append(heading, subtitle, checklist, actions); + overlay.append(panel); + document.body.append(overlay); + + document.body.classList.remove('hidden'); + document.body.classList.add('podcast-studio-mode'); + + let micOk = Boolean(micFresh); + let camOk = Boolean(camFresh); + let diskReady = false; + let destroyed = false; + let diskStatusListener = null; + let resolver; + const completion = new Promise((resolve) => { + resolver = resolve; + }); + + function closeOverlay(result = {}) { + if (destroyed) { + return; + } + destroyed = true; + if (diskStatusListener) { + window.removeEventListener(PODCAST_DISK_EVENT, diskStatusListener); + diskStatusListener = null; + } + if (overlay && overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + const payload = { roomSlug, ...result }; + if (result.completed) { + writePreflightState({ + ...stored, + completedAt: Date.now(), + micSuccessAt: micOk ? (stored.micSuccessAt || Date.now()) : stored.micSuccessAt, + cameraSuccessAt: camOk ? (stored.cameraSuccessAt || Date.now()) : stored.cameraSuccessAt, + roomSlug, + }); + } else { + writePreflightState({ + ...stored, + micSuccessAt: micOk ? (stored.micSuccessAt || Date.now()) : stored.micSuccessAt, + cameraSuccessAt: camOk ? (stored.cameraSuccessAt || Date.now()) : stored.cameraSuccessAt, + roomSlug, + }); + } + if (typeof resolver === 'function') { + resolver(payload); + resolver = null; + } + } + + function updateContinueState() { + continueButton.disabled = !micOk; + continueButton.title = micOk ? '' : 'Run the microphone test to continue.'; + } + + updateContinueState(); + + async function runMediaTest(kind) { + if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') { + throw new Error('Browser does not support media tests.'); + } + const constraints = kind === 'video' ? { video: true } : { audio: { echoCancellation: false } }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch (error) { + console.warn('Unable to stop track', error); + } + }); + } + + if (micRow.actionButton) { + micRow.actionButton.addEventListener('click', async () => { + setPreflightRowState(micRow, 'testing', 'Requesting microphone access…'); + try { + await runMediaTest('audio'); + micOk = true; + const timestamp = Date.now(); + stored.micSuccessAt = timestamp; + setPreflightRowState(micRow, 'ready', 'Microphone ready to record.'); + } catch (error) { + console.error('Microphone check failed', error); + micOk = false; + setPreflightRowState( + micRow, + 'error', + error?.message ? error.message : 'Unable to access microphone.' + ); + } + updateContinueState(); + writePreflightState({ ...stored, micSuccessAt: micOk ? Date.now() : stored.micSuccessAt, roomSlug }); + }); + } + + if (camRow.actionButton) { + camRow.actionButton.addEventListener('click', async () => { + setPreflightRowState(camRow, 'testing', 'Requesting camera access…'); + try { + await runMediaTest('video'); + camOk = true; + const timestamp = Date.now(); + stored.cameraSuccessAt = timestamp; + setPreflightRowState(camRow, 'ready', 'Camera detected.'); + writePreflightState({ ...stored, cameraSuccessAt: timestamp, roomSlug }); + } catch (error) { + console.error('Camera check failed', error); + camOk = false; + setPreflightRowState( + camRow, + 'error', + error?.message ? error.message : 'Unable to access camera.' + ); + writePreflightState({ ...stored, roomSlug }); + } + }); + } + + async function refreshDiskRowStatus({ interactive = false } = {}) { + if (typeof window.showDirectoryPicker !== 'function') { + diskReady = false; + setPreflightRowState( + diskRow, + 'error', + 'Local disk recording requires a Chromium-based browser with the File System Access API.' + ); + if (diskRow.actionButton) { + diskRow.actionButton.disabled = true; + } + return; + } + const diskState = readDiskRecordingState(); + if (!diskState.folderName) { + diskReady = false; + if (diskRow.actionButton) { + diskRow.actionButton.textContent = 'Choose folder'; + diskRow.actionButton.disabled = false; + } + setPreflightRowState(diskRow, 'pending', 'Pick a folder to enable local ISO recording.'); + return; + } + setPreflightRowState(diskRow, 'testing', 'Validating folder permissions…'); + const result = await verifyStoredDiskRecordingDirectory({ requestPermission: interactive }); + if (result.ok) { + diskReady = true; + const meta = readDiskRecordingState(); + if (diskRow.actionButton) { + diskRow.actionButton.textContent = 'Change folder'; + diskRow.actionButton.disabled = false; + } + const checked = meta.lastVerifiedAt ? `Last checked ${formatRelativeTime(meta.lastVerifiedAt)}.` : 'Ready to write.'; + setPreflightRowState(diskRow, 'ready', `Folder: ${result.folderName}. ${checked}`); + } else { + diskReady = false; + if (diskRow.actionButton) { + diskRow.actionButton.textContent = 'Choose folder'; + diskRow.actionButton.disabled = false; + } + setPreflightRowState(diskRow, 'error', result.message || 'Unable to access the selected folder.'); + } + } + + refreshDiskRowStatus(); + diskStatusListener = () => refreshDiskRowStatus(); + window.addEventListener(PODCAST_DISK_EVENT, diskStatusListener); + + if (diskRow.actionButton) { + diskRow.actionButton.addEventListener('click', async () => { + if (diskRow.actionButton.disabled) { + return; + } + try { + setPreflightRowState(diskRow, 'testing', 'Waiting for folder selection…'); + await chooseDiskRecordingDirectory(); + await refreshDiskRowStatus({ interactive: true }); + } catch (error) { + diskReady = false; + const message = + error?.name === 'AbortError' || /cancel/i.test(error?.message || '') + ? 'Folder selection cancelled.' + : error?.message || 'Unable to choose folder.'; + setPreflightRowState(diskRow, 'error', message); + } + }); + } + + continueButton.addEventListener('click', () => { + if (!micOk) { + setPreflightRowState(micRow, 'error', 'Microphone test is required before entering.'); + return; + } + closeOverlay({ completed: true }); + }); + + skipButton.addEventListener('click', () => { + closeOverlay({ skipped: true }); + }); + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + closeOverlay({ skipped: true }); + } + }); + + document.addEventListener( + 'keydown', + (event) => { + if (destroyed) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + closeOverlay({ skipped: true }); + } + }, + { once: true } + ); + + return completion; +} + +function extractPeerAudioStats(peer) { + const stats = peer?.stats || {}; + const candidates = [ + stats.audio_bitrate_kbps, + stats.inbound_audio_bitrate_kbps, + stats.total_audio_bitrate_kbps, + stats.total_sending_bitrate_kbps, + ]; + const audioBitrateKbps = candidates.find((value) => typeof value === 'number' && value >= 0); + const codec = + typeof stats.audio_codec === 'string' + ? stats.audio_codec + : typeof stats.audio_codec_in === 'string' + ? stats.audio_codec_in + : typeof stats.audio_codec_out === 'string' + ? stats.audio_codec_out + : ''; + return { + audioBitrateKbps: audioBitrateKbps ?? null, + audioCodec: codec || null, + }; +} + +function collectParticipants(session) { + const participants = []; + + Object.entries(session.rpcs || {}).forEach(([uuid, peer]) => { + if (!peer) { + return; + } + const audioTracks = peer.streamSrc?.getAudioTracks?.() || []; + let level = 0; + if (peer.stats && typeof peer.stats.Audio_Loudness === 'number') { + level = peer.stats.Audio_Loudness; + } else if (peer.audioMeter) { + level = peer.audioMeter.level || 0; + } + const { audioBitrateKbps, audioCodec } = extractPeerAudioStats(peer); + participants.push({ + uuid, + label: peer.label || peer.streamID || `Guest ${uuid.substring(0, 4)}`, + streamID: peer.streamID, + status: (peer.streamSrc && audioTracks.length) ? 'connected' : 'connecting', + audioLevel: level, + isLocal: false, + role: 'remote', + audioBitrateKbps, + audioCodec, + }); + }); + + return participants; +} + +class PodcastStudioApp { + constructor(options = {}) { + this.options = options || {}; + this.roomHint = this.options.roomHint || ''; + this.session = null; + this.cloud = null; + this.recorder = null; + this.audioContext = null; + this.recording = false; + this.rosterItems = new Map(); + this.rosterDriveButtons = new Map(); + this.rosterDriveStatuses = new Map(); + this.driveStatusResetTimers = new Map(); + this.driveProgressSnapshots = new Map(); + this.meterValues = new Map(); + this.outputIndicators = new Map(); + this.trackRuntimeStats = new Map(); + this.trackLevelNodes = new Map(); + this.spectrograms = new Map(); + this.participantMetrics = new Map(); + this.markers = []; + this.markerActions = null; + this.markerExportButton = null; + this.markerCopyButton = null; + this.markerCopyResetTimer = null; + this.autoMarkerTimeout = null; + this.rosterTimer = null; + this.pendingDriveUploads = []; + this.levelOff = null; + this.recordStartedAt = null; + this.driveStatusNode = null; + this.dropboxStatusNode = null; + this.abortUploadsController = null; + this.activeDownloadUrls = []; + this.stopMeterBridge = null; + this.roomName = this.roomHint || ''; + this.virtualParticipants = new Map(); + this.hostMic = null; + this.hostMicMeter = null; + this.hostMicButton = null; + this.hostMicStatusNode = null; + this.hostMicErrorNode = null; + this.hostMicBusy = false; + this.hostMicMuted = false; + this.hostMuteButton = null; + this.cloudBusy = { + drive: false, + dropbox: false, + }; + this.cloudLinkButtons = { + drive: null, + dropbox: null, + }; + this.cloudLinkStatusNodes = { + drive: null, + dropbox: null, + }; + this.cloudLinkMessages = { + drive: null, + dropbox: null, + }; + this.cloudLinkMessageTextNodes = { + drive: null, + dropbox: null, + }; + this.dropboxTokenInput = null; + this.dropboxTokenRow = null; + this.dropboxGuideRow = null; + this.inviteLinkInput = null; + this.inviteCopyButton = null; + this.inviteStatusNode = null; + this.inviteOptionNodes = {}; + this.inviteCopyTimer = null; + this.remoteOverlay = null; + this.remoteOverlayContent = null; + this.remoteControlState = { + activeUuid: null, + element: null, + placeholder: null, + wrapper: null, + }; + this.cloudProgressNodes = { + drive: null, + dropbox: null, + }; + this.uploadTrackers = { + drive: new Map(), + dropbox: new Map(), + }; + this.chatModule = null; + this.chatPlaceholder = null; + this.chatPanel = null; + this.chatCollapseButton = null; + this.chatPopoutButton = null; + this.chatCollapsed = false; + this.chatPopoutAnchor = null; + this.chatCollapsedHint = null; + this.diskControls = null; + this.diskToggleButton = null; + this.diskFolderButton = null; + this.diskStatusNode = null; + this.diskRecordingEnabled = isDiskRecordingEnabled(); + this.diskStateListener = null; + this.cloudStateListener = null; + this.cloudSummaryNode = null; + this.recordingStatusNode = null; + this.recordingPlan = null; + this.recordingSessionId = null; + this.boundDriveProgressHandler = null; + } + + async init() { + document.body.classList.remove('hidden'); + document.body.classList.add('podcast-studio-mode'); + injectStylesheet(); + + this.session = await waitForLegacySession({ timeoutMs: 15000 }); + this.applyDirectorAudioDefaults(); + this.audioContext = this.session.audioCtx || this.session.audioCtxOutbound || this.createAudioContext(); + this.recorder = new MultiTrackRecorder({ + audioContext: this.audioContext, + includeVideo: false, + includeScreenshares: false, + monitorLevels: true, + timeslice: 1000, + }); + this.cloud = new CloudUploadCoordinator(this.session); + + this.roomName = this.resolveRoomName(); + this.buildLayout(); + if (STUDIO_DISK_FEATURE_FLAG) { + this.diskStateListener = () => { + this.updateDiskRecordingUI(); + this.updateReadinessSummary(); + }; + window.addEventListener(PODCAST_DISK_EVENT, this.diskStateListener); + } + this.cloudStateListener = () => this.updateReadinessSummary(); + window.addEventListener(PODCAST_CLOUD_EVENT, this.cloudStateListener); + this.boundDriveProgressHandler = (event) => this.handleDriveProgressEvent(event); + window.addEventListener(DRIVE_PROGRESS_EVENT, this.boundDriveProgressHandler); + this.updateRoomIndicator(); + this.updateCloudFooter(); + this.attachRecorderEvents(); + this.refreshRoster(); + this.startRosterLoop(); + this.levelOff = levelBus.on(LEVEL_EVENT, (payload) => this.updateMeterFromBus(payload)); + try { + this.stopMeterBridge = await bridgeLegacyMeters(); + } catch (error) { + console.warn('Failed to bridge legacy meter events', error); + } + } + + resolveRoomName() { + if (this.session?.roomid && this.session.roomid !== true) { + return sanitizeRoomSlug(this.session.roomid); + } + if (this.session?.director && this.session.director !== true) { + return sanitizeRoomSlug(this.session.director); + } + if (this.roomHint) { + return sanitizeRoomSlug(this.roomHint); + } + const paramsSlug = getRoomSlugFromParams(); + if (paramsSlug) { + return paramsSlug; + } + return ''; + } + + updateRoomIndicator() { + const latest = this.resolveRoomName(); + if (latest !== this.roomName) { + this.roomName = latest; + if (this.sessionInfo) { + this.sessionInfo.textContent = 'Room: ' + (this.roomName || '—'); + } + if (this.roomName) { + const stored = readStoredRoomState(); + persistStoredRoomState({ room: this.roomName, password: stored?.password || '' }); + } + } else if (!this.roomName && this.sessionInfo) { + this.sessionInfo.textContent = 'Room: —'; + } + this.updateInviteLink(); + } + + describeInviteOptions() { + const labels = []; + if (this.inviteOptionNodes.disableVideo?.checked) { + labels.push('Audio only'); + } else { + labels.push('Video preview'); + } + if (this.inviteOptionNodes.proAudio?.checked) { + labels.push('Pro audio'); + } + if (this.inviteOptionNodes.disableAec?.checked) { + labels.push('AEC off'); + } + if (this.inviteOptionNodes.disableDenoise?.checked) { + labels.push('Denoise off'); + } + if (this.inviteOptionNodes.disableAgc?.checked) { + labels.push('AGC off'); + } + return labels.join(' • '); + } + + applyDirectorAudioDefaults() { + if (!this.session) { + return; + } + if (this.session.stereo === undefined || this.session.stereo === null || this.session.stereo === false || this.session.stereo === 0) { + this.session.stereo = 1; + } + if (!this.session.audiobitrate || this.session.audiobitrate < 192) { + this.session.audiobitrate = 256; + } + if (!this.session.outboundAudioBitrate || this.session.outboundAudioBitrate < 192) { + this.session.outboundAudioBitrate = 256; + } + if (typeof this.session.autoGainControl === 'undefined') { + this.session.autoGainControl = false; + } + if (typeof this.session.noiseSuppression === 'undefined') { + this.session.noiseSuppression = false; + } + if (typeof this.session.echoCancellation === 'undefined') { + this.session.echoCancellation = false; + } + if (typeof this.session.applyStereoDefaults === 'function') { + try { + this.session.applyStereoDefaults(); + } catch (error) { + console.warn('applyStereoDefaults failed', error); + } + } + } + + updateInviteLink() { + if (!this.inviteLinkInput) { + return; + } + const room = this.resolveRoomName(); + if (!room) { + this.inviteLinkInput.value = 'Set a room name to generate a guest link'; + this.inviteLinkInput.dataset.state = 'placeholder'; + if (this.inviteCopyButton) { + this.inviteCopyButton.disabled = true; + } + if (this.inviteStatusNode) { + this.inviteStatusNode.textContent = ''; + } + return; + } + this.inviteLinkInput.dataset.state = 'ready'; + if (this.inviteCopyButton) { + this.inviteCopyButton.disabled = false; + } + const guestUrl = new URL(window.location.href); + guestUrl.search = ''; + guestUrl.hash = ''; + + const params = new URLSearchParams(); + params.set('room', room); + params.set('style', '2'); + params.set('showlabel', '1'); + params.set('tips', '1'); + params.set('label', ''); + + const options = this.inviteOptionNodes || {}; + const summary = []; + summary.push('Label prompt'); + summary.push('Name tag overlay'); + summary.push('Join tips'); + + // Video is ON by default + if (options.disableVideo?.checked) { + params.set('miconly', '1'); + summary.push('Audio only'); + } else { + summary.push('Video enabled'); + } + + if (options.proAudio?.checked) { + params.set('proaudio', '1'); + params.set('stereo', '1'); + params.set('audiobitrate', '256'); + summary.push('Pro audio'); + } else { + params.delete('proaudio'); + params.delete('stereo'); + params.delete('audiobitrate'); + } + + if (options.disableAec?.checked) { + params.set('aec', '0'); + params.set('echocancellation', '0'); + summary.push('AEC off'); + } else { + params.delete('aec'); + params.delete('echocancellation'); + } + + if (options.disableDenoise?.checked) { + params.set('denoise', '0'); + summary.push('Denoise off'); + } else { + params.delete('denoise'); + } + + if (options.disableAgc?.checked) { + params.set('agc', '0'); + params.set('autogain', '0'); + summary.push('AGC off'); + } else { + params.delete('agc'); + params.delete('autogain'); + } + + if (options.guestRecordBackup?.checked) { + params.set('autorecordlocal', '-128'); + summary.push('Audio backup'); + } else { + params.delete('autorecordlocal'); + } + + guestUrl.search = params.toString(); + const value = guestUrl.toString(); + this.inviteLinkInput.value = value; + if (this.inviteStatusNode) { + this.inviteStatusNode.textContent = summary.length ? summary.join(' • ') : 'Default settings'; + } + } + + async copyInviteLink() { + if (!this.inviteLinkInput || this.inviteLinkInput.dataset.state === 'placeholder') { + return; + } + const value = this.inviteLinkInput.value; + if (!value) { + return; + } + const notify = (message, variant = 'info') => { + if (!this.inviteStatusNode) { + return; + } + this.inviteStatusNode.textContent = message; + this.inviteStatusNode.dataset.variant = variant; + if (this.inviteCopyTimer) { + clearTimeout(this.inviteCopyTimer); + } + this.inviteCopyTimer = setTimeout(() => { + this.inviteStatusNode.dataset.variant = ''; + this.updateInviteLink(); + }, 3500); + }; + try { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(value); + notify('Guest link copied.', 'success'); + return; + } + } catch (error) { + console.warn('Navigator clipboard copy failed', error); + } + try { + this.inviteLinkInput.focus(); + this.inviteLinkInput.select(); + const success = document.execCommand('copy'); + if (success) { + notify('Guest link copied.', 'success'); + } else { + notify('Select and copy the link manually.', 'warning'); + } + } catch (error) { + console.warn('Fallback copy failed', error); + notify('Select and copy the link manually.', 'warning'); + } + } + + getAdditionalRecordingParticipants() { + const extras = []; + this.virtualParticipants.forEach((participant) => { + if (participant && participant.stream) { + extras.push({ + ...participant, + }); + } + }); + return extras; + } + + async ensureAudioContextResumed() { + if (!this.audioContext) { + this.audioContext = this.createAudioContext(); + } + if (this.audioContext && typeof this.audioContext.resume === 'function' && this.audioContext.state === 'suspended') { + try { + await this.audioContext.resume(); + } catch (error) { + console.warn('Failed to resume audio context', error); + } + } + } + + setHostMicError(message) { + if (this.hostMicErrorNode) { + this.hostMicErrorNode.textContent = message || ''; + } + } + + updateHostMicUI() { + if (this.hostMicButton) { + if (this.hostMicBusy || this.recording) { + this.hostMicButton.disabled = true; + const busyLabel = this.hostMic?.active ? 'Disabling…' : 'Enabling…'; + this.hostMicButton.textContent = this.recording ? 'Locked' : busyLabel; + } else { + this.hostMicButton.disabled = false; + this.hostMicButton.textContent = this.hostMic?.active ? 'Disable' : 'Enable'; + } + if (this.hostMic?.active) { + this.hostMicButton.classList.add('active'); + } else { + this.hostMicButton.classList.remove('active'); + } + } + if (this.hostMicStatusNode) { + if (this.hostMic?.active) { + this.hostMicStatusNode.textContent = 'Live'; + this.hostMicStatusNode.dataset.state = 'active'; + } else { + this.hostMicStatusNode.textContent = 'Idle'; + this.hostMicStatusNode.dataset.state = 'idle'; + } + } + this.updateHostMuteUI(); + } + + async handleHostMicToggle() { + if (this.recording) { + this.setHostMicError('Stop the recording to change the host mic.'); + return; + } + if (this.hostMicBusy) { + return; + } + if (this.hostMic?.active) { + await this.disableHostMic(); + } else { + await this.enableHostMic(); + } + } + + handleHostMuteToggle() { + if (!this.hostMic?.active) { + return; + } + this.hostMicMuted = !this.hostMicMuted; + if (this.hostMic.track) { + this.hostMic.track.enabled = !this.hostMicMuted; + } + this.updateHostMuteUI(); + } + + updateHostMuteUI() { + if (this.hostMuteButton) { + if (this.hostMic?.active) { + this.hostMuteButton.disabled = false; + this.hostMuteButton.textContent = this.hostMicMuted ? '🔇 Unmute' : '🔊 Mute'; + this.hostMuteButton.classList.toggle('muted', this.hostMicMuted); + } else { + this.hostMuteButton.disabled = true; + this.hostMuteButton.textContent = '🔊 Mute'; + this.hostMuteButton.classList.remove('muted'); + } + } + } + + async enableHostMic() { + if (this.hostMic?.active) { + this.updateHostMicUI(); + return; + } + if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) { + this.setHostMicError('Browser does not support microphone capture.'); + return; + } + this.hostMicBusy = true; + this.setHostMicError(''); + this.updateHostMicUI(); + try { + await this.ensureAudioContextResumed(); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + const [track] = stream.getAudioTracks(); + if (!track) { + throw new Error('No audio track available.'); + } + const label = this.session?.label ? `${this.session.label} (Host)` : 'Host Mic'; + const participant = { + uuid: 'host-mic', + label, + stream, + streamID: 'host-mic', + status: 'connected', + audioLevel: 0, + isLocal: true, + kind: 'local', + role: 'host-mic', + }; + track.addEventListener('ended', () => { + if (this.hostMic?.track === track) { + this.disableHostMic(); + } + }); + this.hostMic = { + active: true, + stream, + track, + uuid: participant.uuid, + label: participant.label, + streamID: participant.streamID, + participant, + }; + this.virtualParticipants.set(participant.uuid, participant); + if (this.audioContext && track) { + try { + this.hostMicMeter = await monitorTrackLevel(this.audioContext, track, { + uuid: participant.uuid, + trackType: 'audio', + metadata: { label: participant.label, source: 'host' }, + }); + } catch (error) { + console.warn('Failed to attach host mic meter', error); + } + } + this.updateHostMicUI(); + this.refreshRoster(); + } catch (error) { + console.error('Failed to enable host microphone', error); + this.setHostMicError(error?.message || 'Unable to access microphone.'); + if (this.hostMic?.stream) { + try { + this.hostMic.stream.getTracks().forEach((mediaTrack) => mediaTrack.stop()); + } catch (stopError) { + console.warn('Failed to stop host mic stream after error', stopError); + } + } + this.hostMic = null; + this.virtualParticipants.delete('host-mic'); + this.updateHostMicUI(); + } finally { + this.hostMicBusy = false; + this.updateHostMicUI(); + } + } + + async disableHostMic() { + if (!this.hostMic?.active && !this.virtualParticipants.has('host-mic')) { + this.hostMic = null; + this.updateHostMicUI(); + return; + } + this.hostMicBusy = true; + this.updateHostMicUI(); + try { + if (this.hostMicMeter) { + try { + this.hostMicMeter.disconnect({ stopTrack: false }); + } catch (error) { + console.warn('Failed to disconnect host mic meter', error); + } + this.hostMicMeter = null; + } + if (this.hostMic?.stream) { + this.hostMic.stream.getTracks().forEach((track) => { + try { + track.stop(); + } catch (error) { + console.warn('Failed to stop host mic track', error); + } + }); + } + } finally { + this.virtualParticipants.delete('host-mic'); + this.hostMic = null; + this.hostMicBusy = false; + this.hostMicMuted = false; + this.setHostMicError(''); + this.updateHostMicUI(); + this.applyMeterValue('host-mic', 0); + this.refreshRoster(); + } + } + + setCloudMessage(service, message, variant = 'info') { + const container = this.cloudLinkMessages?.[service]; + if (!container) { + return; + } + const target = this.cloudLinkMessageTextNodes?.[service] || container; + target.textContent = message || ''; + container.dataset.variant = message ? variant : ''; + } + + updateCloudLinkUI() { + const driveLinked = this.cloud?.hasDriveAccess(); + const dropboxLinked = this.cloud?.hasDropboxAccess(); + const cachedState = readCloudLinkStatus(); + if (!driveLinked && cachedState.drive && !isCloudLinkFresh(cachedState.drive)) { + markCloudUnlinked('drive'); + } + if (!dropboxLinked && cachedState.dropbox && !isCloudLinkFresh(cachedState.dropbox)) { + markCloudUnlinked('dropbox'); + } + + if (this.cloudLinkButtons.drive) { + this.cloudLinkButtons.drive.textContent = driveLinked ? 'Reauthorize Drive' : 'Link Google Drive'; + this.cloudLinkButtons.drive.disabled = Boolean(this.cloudBusy.drive) || this.recording; + this.cloudLinkButtons.drive.dataset.state = driveLinked ? 'linked' : 'idle'; + } + if (this.cloudLinkStatusNodes.drive) { + this.cloudLinkStatusNodes.drive.textContent = driveLinked ? 'Linked' : 'Not linked'; + this.cloudLinkStatusNodes.drive.dataset.state = driveLinked ? 'linked' : 'idle'; + } + if (driveLinked && this.pendingDriveUploads.length) { + this.flushPendingDriveUploads().catch((error) => { + console.warn('Pending Drive uploads failed to resume', error); + }); + } + + if (this.cloudLinkButtons.dropbox) { + this.cloudLinkButtons.dropbox.textContent = dropboxLinked ? 'Refresh Dropbox' : 'Link Dropbox'; + this.cloudLinkButtons.dropbox.disabled = Boolean(this.cloudBusy.dropbox) || this.recording; + this.cloudLinkButtons.dropbox.dataset.state = dropboxLinked ? 'linked' : 'idle'; + } + if (this.cloudLinkStatusNodes.dropbox) { + this.cloudLinkStatusNodes.dropbox.textContent = dropboxLinked ? 'Linked' : 'Not linked'; + this.cloudLinkStatusNodes.dropbox.dataset.state = dropboxLinked ? 'linked' : 'idle'; + } + if (this.dropboxTokenInput) { + this.dropboxTokenInput.disabled = Boolean(this.cloudBusy.dropbox) || this.recording; + } + this.updateAllDriveActions(); + } + + ensureDropboxTokenFallbackVisible({ focus = false, select = false } = {}) { + if (this.dropboxTokenRow) { + this.dropboxTokenRow.hidden = false; + this.dropboxTokenRow.classList.add('cloud-sync-token--visible'); + } + if (this.dropboxGuideRow) { + this.dropboxGuideRow.hidden = false; + this.dropboxGuideRow.classList.add('cloud-sync-token__guide--visible'); + } + if (focus && this.dropboxTokenInput) { + this.dropboxTokenInput.focus(); + if (select && typeof this.dropboxTokenInput.select === 'function') { + this.dropboxTokenInput.select(); + } + } + } + + hideDropboxTokenFallback() { + if (this.dropboxTokenRow) { + this.dropboxTokenRow.hidden = true; + this.dropboxTokenRow.classList.remove('cloud-sync-token--visible'); + } + if (this.dropboxGuideRow) { + this.dropboxGuideRow.hidden = true; + this.dropboxGuideRow.classList.remove('cloud-sync-token__guide--visible'); + } + if (this.dropboxTokenInput) { + this.dropboxTokenInput.value = ''; + } + } + + async handleDiskFolderSelection({ autoEnable = false } = {}) { + if (!STUDIO_DISK_FEATURE_FLAG || !this.diskFolderButton) { + return; + } + if (typeof window.showDirectoryPicker !== 'function') { + this.diskStatusNode.textContent = 'Local disk recording requires Chrome, Edge, or Arc.'; + this.diskStatusNode.dataset.state = 'error'; + return; + } + try { + this.diskFolderButton.disabled = true; + this.diskFolderButton.textContent = '…'; + await chooseDiskRecordingDirectory(); + const result = await verifyStoredDiskRecordingDirectory({ requestPermission: true }); + if (!result.ok) { + throw new Error(result.message || 'Failed'); + } + if (autoEnable || isDiskRecordingEnabled()) { + setDiskRecordingEnabled(true); + this.diskRecordingEnabled = true; + } + } catch (error) { + console.warn('Disk folder selection failed', error); + this.diskStatusNode.textContent = + error?.name === 'AbortError' || /cancel/i.test(error?.message || '') + ? 'Cancelled' + : error?.message || 'Error'; + this.diskStatusNode.dataset.state = 'error'; + } finally { + this.diskFolderButton.disabled = false; + this.updateDiskRecordingUI(); + } + } + + async handleDiskToggle() { + if (!STUDIO_DISK_FEATURE_FLAG || !this.diskToggleButton) { + return; + } + if (typeof window.showDirectoryPicker !== 'function') { + this.diskStatusNode.textContent = 'Browser lacks File System Access API support.'; + this.diskStatusNode.dataset.state = 'error'; + return; + } + const meta = readDiskRecordingState(); + if (!meta.folderName) { + await this.handleDiskFolderSelection({ autoEnable: true }); + return; + } + const nextEnabled = !isDiskRecordingEnabled(); + if (nextEnabled) { + const result = await verifyStoredDiskRecordingDirectory({ requestPermission: true }); + if (!result.ok) { + this.diskStatusNode.textContent = result.message || 'Unable to access the selected folder.'; + this.diskStatusNode.dataset.state = 'error'; + setDiskRecordingEnabled(false); + this.diskRecordingEnabled = false; + this.updateDiskRecordingUI(); + return; + } + } + const finalState = setDiskRecordingEnabled(nextEnabled); + this.diskRecordingEnabled = Boolean(finalState.enabled); + this.updateDiskRecordingUI(); + } + + updateDiskRecordingUI() { + if (!STUDIO_DISK_FEATURE_FLAG || !this.diskControls) { + return; + } + const diskSupported = typeof window.showDirectoryPicker === 'function'; + const meta = readDiskRecordingState(); + const hasFolder = Boolean(meta.folderName); + const enabled = Boolean(meta.enabled && hasFolder); + this.diskRecordingEnabled = enabled; + if (this.diskToggleButton) { + this.diskToggleButton.disabled = !diskSupported; + this.diskToggleButton.dataset.state = enabled ? 'enabled' : 'disabled'; + this.diskToggleButton.textContent = enabled ? 'Armed ✓' : 'Arm'; + this.diskToggleButton.setAttribute('aria-pressed', enabled ? 'true' : 'false'); + } + if (this.diskFolderButton) { + this.diskFolderButton.disabled = !diskSupported; + this.diskFolderButton.textContent = hasFolder ? 'Change' : diskSupported ? 'Folder' : 'N/A'; + } + if (this.diskStatusNode) { + if (!diskSupported) { + this.diskStatusNode.textContent = 'Requires Chrome/Edge'; + this.diskStatusNode.dataset.state = 'error'; + } else if (!hasFolder) { + this.diskStatusNode.textContent = 'No folder selected'; + this.diskStatusNode.dataset.state = 'pending'; + } else if (meta.lastError) { + this.diskStatusNode.textContent = `${meta.folderName} — error`; + this.diskStatusNode.dataset.state = 'error'; + } else { + this.diskStatusNode.textContent = `${meta.folderName} ✓`; + this.diskStatusNode.dataset.state = meta.lastVerifiedAt ? 'ready' : 'pending'; + } + } + this.updateReadinessSummary(); + } + + async ensureDiskCaptureReadiness({ interactive = false } = {}) { + if (!STUDIO_DISK_FEATURE_FLAG || !this.diskRecordingEnabled) { + return { enabled: false, ready: false }; + } + const result = await verifyStoredDiskRecordingDirectory({ requestPermission: interactive }); + if (!result.ok) { + if (this.diskStatusNode) { + this.diskStatusNode.textContent = result.message || 'Unable to access the selected folder.'; + this.diskStatusNode.dataset.state = 'error'; + } + setDiskRecordingEnabled(false); + this.diskRecordingEnabled = false; + this.updateDiskRecordingUI(); + return { + enabled: true, + ready: false, + error: new Error(result.message || 'Folder unavailable'), + }; + } + return { + enabled: true, + ready: true, + folderName: result.folderName, + verifiedAt: Date.now(), + }; + } + + async handleDriveLink() { + if (!this.cloud || this.cloudBusy.drive) { + return; + } + this.cloudBusy.drive = true; + this.updateCloudLinkUI(); + this.setCloudMessage('drive', 'Requesting Google authorization…', 'info'); + try { + const client = this.cloud.ensureDriveClient(); + if (!client) { + throw new Error('Google Drive integration is not available on this build.'); + } + if (typeof client.ensureInitialized === 'function') { + await client.ensureInitialized(); + } + if (typeof client.requestAccessToken === 'function') { + client.requestAccessToken(); + } + if (client.promise && typeof client.promise.then === 'function') { + await client.promise; + } else { + await new Promise((resolve) => setTimeout(resolve, 800)); + } + if (this.cloud.hasDriveAccess()) { + this.setCloudMessage('drive', 'Google Drive linked. Recordings will upload automatically.', 'success'); + const folder = this.session?.GDRIVE_FOLDERNAME || null; + markCloudLinked('drive', { folder }); + await this.flushPendingDriveUploads(); + } else { + this.setCloudMessage('drive', 'Check your popup blocker or try again.', 'warn'); + markCloudUnlinked('drive'); + } + } catch (error) { + console.error('Failed to link Google Drive', error); + this.setCloudMessage('drive', error?.message || 'Failed to link Google Drive.', 'error'); + markCloudUnlinked('drive'); + } finally { + this.cloudBusy.drive = false; + this.updateCloudFooter(); + } + } + + async handleDropboxLink() { + if (!this.cloud || this.cloudBusy.dropbox) { + return; + } + this.cloudBusy.dropbox = true; + this.updateCloudLinkUI(); + const providedToken = (this.dropboxTokenInput?.value || '').trim(); + if (providedToken) { + this.ensureDropboxTokenFallbackVisible(); + } + const interactive = !providedToken; + const hasExistingAccess = this.cloud?.hasDropboxAccess(); + const forceReauth = !providedToken && hasExistingAccess; + const pendingMessage = providedToken + ? 'Linking Dropbox with the provided token…' + : hasExistingAccess + ? 'Refreshing Dropbox session…' + : 'Waiting for the Dropbox popup to complete…'; + this.setCloudMessage('dropbox', pendingMessage, 'info'); + try { + if (typeof window.setupDropbox !== 'function') { + throw new Error('Dropbox uploader is not available in this build.'); + } + const client = await this.cloud.ensureDropboxClient(providedToken || undefined, { interactive, forceReauth }); + if (client) { + this.setCloudMessage('dropbox', 'Dropbox linked. Recordings will upload automatically.', 'success'); + if (this.dropboxTokenInput) { + this.dropboxTokenInput.value = ''; + } + if (!providedToken) { + this.hideDropboxTokenFallback(); + } + markCloudLinked('dropbox'); + } else { + markCloudUnlinked('dropbox'); + if (providedToken) { + this.setCloudMessage('dropbox', 'Dropbox rejected the provided token. Double-check and try again.', 'error'); + this.ensureDropboxTokenFallbackVisible({ focus: true, select: true }); + } else { + this.setCloudMessage('dropbox', 'Dropbox authorization was cancelled. Check your popup blocker and try again.', 'warn'); + this.ensureDropboxTokenFallbackVisible({ focus: true }); + } + } + } catch (error) { + console.error('Failed to init Dropbox', error); + this.setCloudMessage('dropbox', error?.message || 'Unable to initialise Dropbox.', 'error'); + this.ensureDropboxTokenFallbackVisible({ focus: true }); + markCloudUnlinked('dropbox'); + } finally { + this.cloudBusy.dropbox = false; + this.updateCloudFooter(); + } + } + + createAudioContext() { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) { + return null; + } + return new AudioCtx(); + } + + buildLayout() { + if (document.getElementById(STUDIO_ROOT_ID)) { + return; + } + + const root = createElement('div', '', { id: STUDIO_ROOT_ID }); + + // Header + const header = createElement('header', 'podcast-header'); + const title = createElement('h1', '', { text: 'Podcast Control Room' }); + const statusPill = createElement('div', 'podcast-status-pill'); + statusPill.innerHTML = 'Live-ready'; + header.append(title, statusPill); + + // Main layout + const main = createElement('main', 'podcast-main'); + + // Left column (host input + roster + markers) + const rosterColumn = createElement('div', 'podcast-roster'); + + // Host Input panel (director's mic) + const hostPanel = createElement('section', 'podcast-panel host-panel'); + hostPanel.append(createElement('h2', '', { text: '🎙️ Host Input' })); + const hostControls = createElement('div', 'host-input-content'); + this.hostMicButton = createElement('button', 'host-input-toggle', { type: 'button', text: 'Enable', title: 'Toggle local host microphone capture (optional).'}); + this.hostMicButton.addEventListener('click', () => this.handleHostMicToggle()); + this.hostMuteButton = createElement('button', 'host-mute-toggle', { type: 'button', text: '🔊 Mute', title: 'Mute/unmute the host mic track.' }); + this.hostMuteButton.disabled = true; + this.hostMuteButton.addEventListener('click', () => this.handleHostMuteToggle()); + this.hostMicStatusNode = createElement('div', 'host-input-status', { text: 'Idle' }); + hostControls.append(this.hostMicButton, this.hostMuteButton, this.hostMicStatusNode); + this.hostMicErrorNode = createElement('div', 'host-input-error'); + hostPanel.append(hostControls, this.hostMicErrorNode); + + const rosterPanel = createElement('section', 'podcast-panel'); + this.rosterList = createElement('div', 'roster-list'); + rosterPanel.append(this.rosterList); + makeCollapsible(rosterPanel, 'Talent Roster', 'podcastStudio.collapse.roster'); + + const markersPanel = createElement('section', 'podcast-panel'); + this.markerLog = createElement('div', 'marker-log'); + const emptyMarkers = createElement('div', 'empty-state', { text: 'Tap “Marker” to drop cue points during recording.' }); + emptyMarkers.dataset.empty = 'true'; + this.markerLog.append(emptyMarkers); + this.markerActions = createElement('div', 'cloud-sync-list__actions marker-actions'); + this.markerActions.style.display = 'none'; + this.markerExportButton = createElement('button', 'cloud-sync-list__button', { type: 'button', text: 'Export CSV', title: 'Download markers as a CSV file.' }); + this.markerExportButton.addEventListener('click', () => this.exportMarkersCsv()); + this.markerCopyButton = createElement('button', 'cloud-sync-list__button', { type: 'button', text: 'Copy CSV', title: 'Copy markers CSV to clipboard.' }); + this.markerCopyButton.addEventListener('click', () => this.copyMarkersCsv()); + this.markerActions.append(this.markerExportButton, this.markerCopyButton); + markersPanel.append(this.markerLog, this.markerActions); + makeCollapsible(markersPanel, 'Session Markers', 'podcastStudio.collapse.markers'); + + rosterColumn.append(hostPanel, rosterPanel, markersPanel); + + // Right column (controls + timeline) + const consoleColumn = createElement('div', 'podcast-console'); + const consoleGrid = createElement('div', 'podcast-console-grid'); + consoleColumn.append(consoleGrid); + + const invitePanel = createElement('section', 'podcast-panel invite-panel'); + invitePanel.classList.add('console-grid__span-2'); + const inviteIntro = createElement('p', 'invite-copy', { + text: 'Share a pro audio-ready link with guests. Tweak processing flags before copying.', + }); + const inviteLinkRow = createElement('div', 'invite-link-row'); + this.inviteLinkInput = createElement('input', 'invite-link-input', { + type: 'text', + readonly: 'true', + value: '', + title: 'Guest invite link (click to select).', + }); + this.inviteLinkInput.addEventListener('focus', () => { + try { + this.inviteLinkInput.select(); + } catch (error) { + console.warn('Invite link select failed', error); + } + }); + this.inviteCopyButton = createElement('button', 'invite-link-copy', { type: 'button', text: 'Copy link', title: 'Copy the guest invite link.' }); + this.inviteCopyButton.addEventListener('click', () => this.copyInviteLink()); + inviteLinkRow.append(this.inviteLinkInput, this.inviteCopyButton); + this.inviteStatusNode = createElement('div', 'invite-status'); + + const inviteOptions = createElement('div', 'invite-options'); + const optionDefs = [ + { key: 'disableVideo', label: 'Disable video preview', defaultChecked: false }, + { key: 'proAudio', label: 'Enable pro audio (stereo, 256 kbps)', defaultChecked: true }, + { key: 'disableAec', label: 'Disable echo cancellation', defaultChecked: true }, + { key: 'disableDenoise', label: 'Disable noise reduction', defaultChecked: true }, + { key: 'disableAgc', label: 'Disable auto gain control', defaultChecked: true }, + { key: 'guestRecordBackup', label: 'Guest-side audio record backup', defaultChecked: true }, + ]; + optionDefs.forEach((option) => { + const optionLabel = createElement('label', 'invite-option'); + const checkbox = createElement('input', 'invite-option__checkbox', { type: 'checkbox' }); + checkbox.checked = option.defaultChecked; + checkbox.title = 'Applies to the generated guest link.'; + optionLabel.title = option.label; + checkbox.addEventListener('change', () => this.updateInviteLink()); + optionLabel.append(checkbox, createElement('span', 'invite-option__label', { text: option.label })); + inviteOptions.append(optionLabel); + this.inviteOptionNodes[option.key] = checkbox; + }); + + invitePanel.append(inviteIntro, inviteLinkRow, this.inviteStatusNode, inviteOptions); + makeCollapsible(invitePanel, 'Guest Invites', 'podcastStudio.collapse.invites'); + + const sessionToolsPanel = createElement('section', 'podcast-panel session-tools'); + sessionToolsPanel.classList.add('console-grid__span-2'); + const sessionToolsGrid = createElement('div', 'session-tools__grid'); + sessionToolsPanel.append(sessionToolsGrid); + + const controlCard = createElement('div', 'session-tool session-tool--control'); + controlCard.append(createElement('h2', 'session-tool__title', { text: '⏺ ISO Track Recording' })); + + // Buttons row + const transportButtons = createElement('div', 'transport-buttons'); + this.recordButton = createElement('button', '', { type: 'button', text: '⏺ Record Audio', title: 'Start/stop ISO WAV recording (per-speaker tracks).' }); + this.recordButton.addEventListener('click', () => this.handleRecordToggle()); + this.markerButton = createElement('button', '', { type: 'button', text: 'Marker', title: 'Drop a cue marker at the current time.' }); + this.markerButton.disabled = true; + this.markerButton.addEventListener('click', () => this.addMarker()); + transportButtons.append(this.recordButton, this.markerButton); + + // Video recording row + const videoRecordSection = createElement('div', 'transport-strip transport-strip--video'); + this.recordShowButton = createElement('button', '', { type: 'button', text: '🎬 Record Group', title: 'Open a popup window with the combined scene for recording.' }); + this.recordShowButton.addEventListener('click', () => this.openRecordShowWindow()); + const videoHint = createElement('span', 'video-record-hint', { text: 'Opens popup with combined scene' }); + const videoIsoTip = createElement('a', 'video-iso-tip', { + text: 'Individual video guide →', + href: 'https://www.youtube.com/watch?v=s5shpEqLZbM', + target: '_blank', + rel: 'noopener', + title: 'Open a guide for recording individual video tracks.', + }); + videoRecordSection.append(this.recordShowButton, videoHint, videoIsoTip); + + // Status row + const statusRow = createElement('div', 'recording-status-row'); + this.sessionInfo = createElement('div', 'session-info', { text: 'Room: ' + (this.roomName || '—') }); + this.recordingStatusNode = createElement('div', 'session-recording-status', { text: 'Idle' }); + this.recordingStatusNode.dataset.state = 'idle'; + statusRow.append(this.sessionInfo, this.recordingStatusNode); + + controlCard.append(transportButtons, videoRecordSection, statusRow); + sessionToolsGrid.append(controlCard); + + // ISO Recording Configuration - unified destinations section + const isoConfigCard = createElement('div', 'session-tool session-tool--iso-config'); + isoConfigCard.append(createElement('h2', 'session-tool__title', { text: '💾 ISO Recording Destinations' })); + const isoConfigList = createElement('div', 'iso-config-list'); + + // Google Drive row + const driveRow = createElement('div', 'iso-config-row'); + driveRow.append(createElement('div', 'iso-config-row__label', { text: 'Google Drive' })); + const driveActions = createElement('div', 'iso-config-row__actions'); + this.cloudLinkButtons.drive = createElement('button', 'iso-config-row__button', { type: 'button', text: 'Link', title: 'Authorize Google Drive uploads for ISO tracks.' }); + this.cloudLinkButtons.drive.addEventListener('click', () => this.handleDriveLink()); + this.cloudLinkButtons.drive.dataset.state = 'idle'; + this.cloudLinkStatusNodes.drive = createElement('span', 'iso-config-row__status', { text: 'Not linked' }); + this.cloudLinkStatusNodes.drive.dataset.state = 'idle'; + driveActions.append(this.cloudLinkButtons.drive, this.cloudLinkStatusNodes.drive); + driveRow.append(driveActions); + this.cloudLinkMessages.drive = createElement('div', 'iso-config-row__hint'); + this.cloudLinkMessages.drive.dataset.variant = ''; + const driveMessageText = createElement('span', 'iso-config-row__hint-text'); + this.cloudLinkMessages.drive.append(driveMessageText); + this.cloudLinkMessageTextNodes.drive = driveMessageText; + isoConfigList.append(driveRow, this.cloudLinkMessages.drive); + + // Dropbox row + const dropboxRow = createElement('div', 'iso-config-row'); + dropboxRow.append(createElement('div', 'iso-config-row__label', { text: 'Dropbox' })); + const dropboxActions = createElement('div', 'iso-config-row__actions'); + this.cloudLinkButtons.dropbox = createElement('button', 'iso-config-row__button', { type: 'button', text: 'Link', title: 'Authorize Dropbox uploads for ISO tracks.' }); + this.cloudLinkButtons.dropbox.addEventListener('click', () => this.handleDropboxLink()); + this.cloudLinkButtons.dropbox.dataset.state = 'idle'; + this.cloudLinkStatusNodes.dropbox = createElement('span', 'iso-config-row__status', { text: 'Not linked' }); + this.cloudLinkStatusNodes.dropbox.dataset.state = 'idle'; + dropboxActions.append(this.cloudLinkButtons.dropbox, this.cloudLinkStatusNodes.dropbox); + dropboxRow.append(dropboxActions); + this.cloudLinkMessages.dropbox = createElement('div', 'iso-config-row__hint'); + this.cloudLinkMessages.dropbox.dataset.variant = ''; + const dropboxMessageText = createElement('span', 'iso-config-row__hint-text'); + this.cloudLinkMessages.dropbox.append(dropboxMessageText); + this.cloudLinkMessageTextNodes.dropbox = dropboxMessageText; + const tokenFieldId = 'podcast-dropbox-token'; + const dropboxTokenRow = createElement('div', 'cloud-sync-token'); + this.dropboxTokenRow = dropboxTokenRow; + const tokenLabel = createElement('label', 'cloud-sync-token__label', { text: 'Access token' }); + tokenLabel.setAttribute('for', tokenFieldId); + this.dropboxTokenInput = createElement('input', 'cloud-sync-token__input', { + type: 'password', + placeholder: 'Paste Dropbox personal access token', + id: tokenFieldId, + spellcheck: 'false', + autocapitalize: 'none', + autocomplete: 'off', + title: 'Fallback: paste a Dropbox token if the Link popup is unavailable.', + }); + dropboxTokenRow.append(tokenLabel, this.dropboxTokenInput); + const dropboxGuideRow = createElement('div', 'cloud-sync-token__guide'); + this.dropboxGuideRow = dropboxGuideRow; + const guideLink = createElement('a', 'cloud-sync-guide-link', { + text: 'Open the Dropbox setup guide', + href: DROPBOX_GUIDE_URL, + target: '_blank', + rel: 'noopener', + title: 'Open the Dropbox setup guide in a new tab.', + }); + dropboxGuideRow.append('Need a token? ', guideLink); + this.cloudLinkMessages.dropbox.append(dropboxTokenRow, dropboxGuideRow); + this.hideDropboxTokenFallback(); + isoConfigList.append(dropboxRow, this.cloudLinkMessages.dropbox); + + // Local Disk row + if (STUDIO_DISK_FEATURE_FLAG) { + const diskSupported = typeof window.showDirectoryPicker === 'function'; + const diskRow = createElement('div', 'iso-config-row'); + diskRow.append(createElement('div', 'iso-config-row__label', { text: 'Local Disk' })); + const diskActions = createElement('div', 'iso-config-row__actions'); + this.diskControls = diskActions; + this.diskToggleButton = createElement('button', 'iso-config-row__button', { + type: 'button', + text: 'Arm', + title: 'Arm/disarm recording ISO files to local disk.', + }); + this.diskToggleButton.addEventListener('click', () => this.handleDiskToggle()); + this.diskFolderButton = createElement('button', 'iso-config-row__button iso-config-row__button--secondary', { + type: 'button', + text: diskSupported ? 'Folder' : 'N/A', + title: diskSupported ? 'Choose the destination folder for disk recording.' : 'Local disk recording is not supported in this browser.', + }); + this.diskFolderButton.disabled = !diskSupported; + if (diskSupported) { + this.diskFolderButton.addEventListener('click', () => this.handleDiskFolderSelection()); + } + this.diskStatusNode = createElement('span', 'iso-config-row__status', { + text: diskSupported ? 'No folder' : 'Not supported', + }); + diskActions.append(this.diskToggleButton, this.diskFolderButton, this.diskStatusNode); + diskRow.append(diskActions); + isoConfigList.append(diskRow); + this.updateDiskRecordingUI(); + } + + // Summary section + const isoSummary = createElement('div', 'iso-config-summary'); + this.cloudSummaryNode = createElement('div', 'iso-config-summary__item', { text: 'Status: checking...' }); + this.cloudSummaryNode.dataset.state = 'pending'; + isoSummary.append(this.cloudSummaryNode); + + isoConfigCard.append(isoConfigList, isoSummary); + sessionToolsGrid.append(isoConfigCard); + makeCollapsible(sessionToolsPanel, 'Recording Controls', 'podcastStudio.collapse.recording'); + + const timelinePanel = createElement('section', 'podcast-panel timeline-shell'); + timelinePanel.classList.add('console-grid__span-2'); + this.outputsContainer = createElement('div', 'timeline-surface'); + timelinePanel.append(this.outputsContainer); + this.showOutputsMessage('Recordings and cue points will appear here.'); + makeCollapsible(timelinePanel, 'Timeline & Outputs', 'podcastStudio.collapse.timeline'); + + const chatPanel = createElement('section', 'podcast-panel chat-panel'); + chatPanel.dataset.collapsed = 'false'; + this.chatPanel = chatPanel; + const chatHeaderRow = createElement('div', 'chat-panel__header'); + const chatTitle = createElement('h2', '', { text: 'Control Room Chat' }); + const chatActions = createElement('div', 'chat-panel__actions'); + this.chatPopoutButton = createElement('button', 'chat-panel__action', { type: 'button', text: 'Pop out', title: 'Open chat in a separate window.' }); + this.chatPopoutButton.addEventListener('click', () => this.handleChatPopout()); + this.chatCollapseButton = createElement('button', 'panel-collapse-toggle', { + type: 'button', + text: '−', + }); + this.chatCollapseButton.title = 'Collapse section'; + this.chatCollapseButton.setAttribute('aria-expanded', 'true'); + this.chatCollapseButton.addEventListener('click', () => this.toggleChatPanel()); + chatActions.append(this.chatPopoutButton, this.chatCollapseButton); + chatHeaderRow.append(chatTitle, chatActions); + chatPanel.append(chatHeaderRow); + + const chatBody = createElement('div', 'chat-panel__body'); + const legacyChat = document.getElementById('chatModule'); + if (legacyChat) { + this.chatPlaceholder = document.createElement('div'); + this.chatPlaceholder.dataset.podcastPlaceholder = 'chat-module'; + if (legacyChat.parentNode) { + legacyChat.parentNode.insertBefore(this.chatPlaceholder, legacyChat); + } + legacyChat.classList.remove('hidden'); + legacyChat.dataset.podcastOverlay = 'true'; + const legacyHeader = legacyChat.querySelector('.chat-header'); + if (legacyHeader) { + const popLink = legacyHeader.querySelector('#popOutChat'); + if (popLink) { + this.chatPopoutAnchor = popLink; + popLink.style.display = 'none'; + } + const closeLink = legacyHeader.querySelector('#closeChat'); + if (closeLink) { + closeLink.style.display = 'none'; + } + legacyHeader.dataset.podcastDisplay = legacyHeader.style.display || ''; + legacyHeader.style.display = 'none'; + } + const legacyResizer = legacyChat.querySelector('.resizer'); + if (legacyResizer) { + legacyResizer.dataset.podcastDisplay = legacyResizer.style.display || ''; + legacyResizer.style.display = 'none'; + } + legacyChat.style.position = 'relative'; + legacyChat.style.right = 'auto'; + legacyChat.style.left = 'auto'; + legacyChat.style.bottom = 'auto'; + legacyChat.style.top = 'auto'; + legacyChat.style.zIndex = 'auto'; + legacyChat.style.maxWidth = '100%'; + legacyChat.style.width = '100%'; + legacyChat.style.height = 'auto'; + legacyChat.style.maxHeight = '320px'; + legacyChat.style.overflow = 'hidden'; + legacyChat.style.margin = '0'; + chatBody.append(legacyChat); + this.chatModule = legacyChat; + } else { + chatBody.append(createElement('div', 'chat-panel__empty', { text: 'Chat initialising…' })); + } + if (this.chatPopoutButton) { + const hasPopout = Boolean(this.chatPopoutAnchor || typeof window.createPopoutChat === 'function'); + this.chatPopoutButton.disabled = !hasPopout; + if (!hasPopout) { + this.chatPopoutButton.textContent = 'Pop out unavailable'; + } + } + chatPanel.append(chatBody); + this.chatCollapsedHint = createElement('div', 'chat-panel__collapsed-hint', { + text: 'Chat hidden. Click “Show chat” to reopen.', + }); + this.chatCollapsedHint.style.display = 'none'; + chatPanel.append(this.chatCollapsedHint); + + if (this.chatModule) { + this.chatModule.classList.remove('hidden'); + this.chatModule.style.display = ''; + this.chatModule.dataset.podcastEmbedded = 'true'; + } + + consoleGrid.append(invitePanel); + consoleGrid.append(sessionToolsPanel); + consoleGrid.append(timelinePanel); + chatPanel.classList.add('console-grid__span-2'); + consoleGrid.append(chatPanel); + + main.append(rosterColumn, consoleColumn); + + // Footer + const footer = createElement('footer', 'podcast-footer'); + footer.innerHTML = ` +
Powered by VDO.Ninja • Low-latency P2P backbone intact • Guide
+
+ ${this.cloud?.hasDriveAccess() ? 'Google Drive linked' : 'Drive link pending'} + ${this.cloud?.hasDropboxAccess() ? 'Dropbox linked' : 'Dropbox link pending'} +
+ `; + + root.append(header, main, footer); + document.body.appendChild(root); + + // Help link click handler (must be after appendChild) + const helpLink = document.getElementById('podcast-help-link'); + if (helpLink) { + helpLink.addEventListener('click', () => this.openHelpModal()); + helpLink.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openHelpModal(); + } + }); + } + + this.driveStatusNode = document.getElementById('podcast-cloud-drive'); + this.dropboxStatusNode = document.getElementById('podcast-cloud-dropbox'); + this.updateHostMicUI(); + this.setHostMicError(''); + this.updateCloudLinkUI(); + this.updateReadinessSummary(); + this.setCloudMessage('drive', ''); + this.setCloudMessage('dropbox', ''); + this.updateInviteLink(); + this.toggleChatPanel(false); + requestAnimationFrame(() => this.toggleChatPanel(false)); + } + + handleChatPopout() { + if (this.chatPopoutAnchor && typeof this.chatPopoutAnchor.click === 'function') { + try { + this.chatPopoutAnchor.click(); + return; + } catch (error) { + console.warn('Legacy chat pop-out click failed', error); + } + } + if (typeof window.createPopoutChat === 'function') { + try { + window.createPopoutChat(); + return; + } catch (error) { + console.warn('createPopoutChat invocation failed', error); + } + } + try { + window.open(window.location.href, '_blank', 'noopener'); + } catch (error) { + console.warn('Fallback chat pop-out failed', error); + } + } + + toggleChatPanel(forceCollapsed) { + const shouldCollapse = typeof forceCollapsed === 'boolean' ? forceCollapsed : !this.chatCollapsed; + this.chatCollapsed = Boolean(shouldCollapse); + if (this.chatPanel) { + this.chatPanel.dataset.collapsed = this.chatCollapsed ? 'true' : 'false'; + } + if (this.chatCollapseButton) { + this.chatCollapseButton.textContent = this.chatCollapsed ? '+' : '−'; + this.chatCollapseButton.title = this.chatCollapsed ? 'Expand section' : 'Collapse section'; + this.chatCollapseButton.setAttribute('aria-expanded', this.chatCollapsed ? 'false' : 'true'); + } + if (this.chatCollapsedHint) { + this.chatCollapsedHint.style.display = this.chatCollapsed ? '' : 'none'; + } + if (this.chatModule) { + if (this.chatCollapsed) { + this.chatModule.classList.add('hidden'); + } else { + this.chatModule.classList.remove('hidden'); + } + } + if (this.session && typeof this.session.chat !== 'undefined') { + this.session.chat = !this.chatCollapsed; + } + } + + attachRecorderEvents() { + this.recorder.addEventListener('start', (event) => { + if (this.abortUploadsController) { + this.abortUploadsController.abort(); + } + this.abortUploadsController = new AbortController(); + this.cleanupDownloadUrls(); + this.trackRuntimeStats.clear(); + this.trackLevelNodes.clear(); + this.teardownSpectrograms(); + this.outputIndicators.clear(); + this.recording = true; + this.recordStartedAt = event?.detail?.startedAt || Date.now(); + this.markers = []; + this.renderMarkers(); + this.scheduleAutoSyncMarker(); + this.recordButton.classList.add('recording'); + this.recordButton.textContent = 'Stop Recording'; + this.markerButton.disabled = false; + this.showOutputsMessage('Recording… tracks will appear as audio arrives.'); + this.updateHostMicUI(); + this.setUploadProgressPending(true); + if (this.recordingPlan?.sync) { + this.recordingPlan.sync.start = { + wallClock: this.recordStartedAt, + highRes: snapshotHighResClock(), + }; + } + this.logRecordingEvent('record:start', { sessionId: this.recordingSessionId }); + this.updateRecordingPlanStatus('started', { events: this.recordingPlan?.events || [] }); + this.setRecordingStatus('Recording in progress', 'active'); + }); + + this.recorder.addEventListener('chunk', (event) => { + const { participant, trackType, channelIndex } = event.detail || {}; + if (!participant || !trackType) { + return; + } + const channelKey = typeof channelIndex === 'number' ? channelIndex : 0; + const key = this.buildTrackKey(participant.uuid, trackType, channelKey); + if (!key) { + return; + } + const indicator = this.ensureOutputIndicator(key, participant, trackType, channelKey); + if (!indicator) { + return; + } + indicator.badge.textContent = 'Recording'; + indicator.wrapper.dataset.state = 'recording'; + this.updateRecordingRuntimeMetrics(key, indicator, event.detail); + this.trackManifestChunk(event.detail); + }); + + this.recorder.addEventListener('meter-ready', (event) => { + const { participant, trackType, channelIndex, meter } = event.detail || {}; + if (!participant?.uuid || trackType !== 'audio') { + return; + } + const key = this.buildTrackKey(participant.uuid, trackType, channelIndex); + if (!key) { + return; + } + const indicator = this.outputIndicators.get(key); + if (!indicator) { + return; + } + this.attachSpectrogram(key, indicator, participant, trackType, channelIndex, meter); + }); + + this.recorder.addEventListener('participant-added', (event) => { + const { participant, trackCount, startOffsetSeconds } = event.detail || {}; + if (!participant) { + return; + } + // Create output indicators immediately for the new participant's tracks + for (let i = 0; i < (trackCount || 1); i += 1) { + const key = this.buildTrackKey(participant.uuid, 'audio', i); + if (key) { + const indicator = this.ensureOutputIndicator(key, participant, 'audio', i); + if (indicator?.badge) { + indicator.badge.textContent = 'Late join'; + indicator.badge.title = `Joined ${startOffsetSeconds?.toFixed(1) || '?'}s into recording`; + } + } + } + }); + + this.recorder.addEventListener('error', (event) => { + console.error('Recorder error', event.detail); + this.setStatusMessage('Recorder error: ' + (event.detail?.message || 'unknown')); + }); + + this.recorder.addEventListener('stop', (event) => { + this.recording = false; + this.recordButton.classList.remove('recording'); + this.recordButton.textContent = '⏺ Record Audio Tracks'; + this.markerButton.disabled = true; + if (this.autoMarkerTimeout) { + clearTimeout(this.autoMarkerTimeout); + this.autoMarkerTimeout = null; + } + this.showOutputsMessage('Finalising recordings…'); + this.trackLevelNodes.clear(); + this.teardownSpectrograms(); + this.presentRecordings(event.detail?.files); + this.outputIndicators.clear(); + this.trackRuntimeStats.clear(); + this.updateHostMicUI(); + if (this.recordingPlan?.sync) { + this.recordingPlan.sync.stop = { + wallClock: Date.now(), + highRes: snapshotHighResClock(), + }; + } + if (this.recordingPlan) { + this.recordingPlan.files = this.summariseRecordingFiles(event.detail?.files); + this.logRecordingEvent('record:stop', { fileCount: this.recordingPlan?.files?.length || 0 }); + this.updateRecordingPlanStatus('stopped', { + files: this.recordingPlan.files, + events: this.recordingPlan.events, + }); + } + this.setRecordingStatus('Recording idle', 'idle'); + }); + } + + ensureOutputIndicator(key, participant, trackType, channelIndex = 0) { + if (!this.outputsContainer) { + return null; + } + if (this.outputIndicators.has(key)) { + return this.outputIndicators.get(key); + } + + this.prepareTracklistSurface(); + + if (!this.outputsContainer.dataset.hasTracks) { + this.outputsContainer.innerHTML = ''; + this.outputsContainer.dataset.hasTracks = 'true'; + } + + const wrapper = createElement('div', 'timeline-track'); + wrapper.dataset.key = key; + wrapper.dataset.trackType = trackType; + wrapper.dataset.participant = participant.uuid || ''; + wrapper.dataset.state = 'armed'; + + const header = createElement('div', 'timeline-track__header'); + const titleGroup = createElement('div', 'timeline-track__title-group'); + const title = createElement('div', 'timeline-track__title', { text: participant.label || participant.uuid || 'Guest' }); + const descriptorParts = []; + if (participant.external || participant.uuid === 'host-mic') { + descriptorParts.push('Local input'); + } else if (participant.streamID) { + descriptorParts.push(`Stream ${participant.streamID}`); + } + descriptorParts.push(trackType ? trackType.toUpperCase() : 'AUDIO'); + descriptorParts.push(`Channel ${channelIndex + 1}`); + const subtitle = createElement('div', 'timeline-track__subtitle', { + text: descriptorParts.filter(Boolean).join(' • '), + }); + titleGroup.append(title, subtitle); + const badge = createElement('span', 'timeline-track__badge', { text: 'Arming' }); + header.append(titleGroup, badge); + + const metrics = createElement('div', 'timeline-track__metrics'); + const inboundMetric = createElement('span', 'timeline-track__metric timeline-track__metric--inbound', { + text: participant.external || participant.uuid === 'host-mic' ? 'Inbound: Local capture' : 'Inbound: pending…', + }); + const recordMetric = createElement('span', 'timeline-track__metric timeline-track__metric--recording', { + text: 'Recording: waiting…', + }); + metrics.append(inboundMetric, recordMetric); + + const waveform = createElement('div', 'timeline-track__waveform'); + const spectrogramCanvas = document.createElement('canvas'); + spectrogramCanvas.className = 'timeline-track__spectrogram'; + const waveFill = createElement('div', 'timeline-track__wavefill'); + waveform.append(spectrogramCanvas, waveFill); + + wrapper.append(header, metrics, waveform); + this.outputsContainer.append(wrapper); + + const indicator = { + key, + wrapper, + badge, + inboundMetric, + recordMetric, + waveFill, + spectrogramCanvas, + participant, + trackType, + channelIndex, + }; + + this.outputIndicators.set(key, indicator); + this.registerTrackLevelNode(participant.uuid, waveFill); + this.updateTrackInboundMetric(participant.uuid); + this.attachSpectrogram(key, indicator, participant, trackType, channelIndex); + return indicator; + } + + setStatusMessage(message) { + this.showOutputsMessage(message); + } + + showOutputsMessage(text) { + if (!this.outputsContainer) { + return; + } + this.outputsContainer.dataset.mode = 'message'; + this.outputsContainer.dataset.hasTracks = ''; + this.outputsContainer.classList.remove('timeline-tracklist'); + this.outputsContainer.classList.remove('timeline-results'); + this.outputsContainer.innerHTML = ''; + if (typeof text === 'string' && text.trim()) { + this.outputsContainer.append(createElement('div', 'timeline-placeholder', { text })); + } else { + this.outputsContainer.append(createElement('div', 'timeline-placeholder', { text: '' })); + } + } + + prepareTracklistSurface({ reset = false } = {}) { + if (!this.outputsContainer) { + return; + } + const switchingMode = this.outputsContainer.dataset.mode !== 'recording'; + if (switchingMode || reset) { + this.outputsContainer.innerHTML = ''; + this.outputsContainer.dataset.hasTracks = ''; + } + this.outputsContainer.dataset.mode = 'recording'; + this.outputsContainer.classList.add('timeline-tracklist'); + this.outputsContainer.classList.remove('timeline-results'); + } + + buildTrackKey(uuid, trackType, channelIndex = 0) { + if (!uuid || !trackType) { + return ''; + } + const index = typeof channelIndex === 'number' ? channelIndex : 0; + return `${uuid}-${trackType}-${index}`; + } + + getMeterForTrack(uuid, trackType, channelIndex = 0) { + if (!this.recorder || typeof this.recorder.getTrackMeter !== 'function') { + return null; + } + return this.recorder.getTrackMeter(uuid, trackType, channelIndex); + } + + attachSpectrogram(key, indicator, participant, trackType, channelIndex, meterOverride = null) { + if (!key || trackType !== 'audio' || !indicator?.spectrogramCanvas) { + return; + } + let renderer = this.spectrograms.get(key); + if (!renderer) { + renderer = new SpectrogramRenderer(indicator.spectrogramCanvas); + this.spectrograms.set(key, renderer); + } + if (!participant?.uuid) { + return; + } + const meter = meterOverride || this.getMeterForTrack(participant.uuid, trackType, channelIndex); + if (meter?.analyser) { + renderer.setAnalyser(meter.analyser); + } + } + + teardownSpectrograms() { + if (!this.spectrograms) { + return; + } + this.spectrograms.forEach((renderer) => { + if (renderer && typeof renderer.destroy === 'function') { + renderer.destroy(); + } + }); + this.spectrograms.clear(); + } + + registerTrackLevelNode(uuid, node) { + if (!uuid || !node) { + return; + } + if (!this.trackLevelNodes.has(uuid)) { + this.trackLevelNodes.set(uuid, new Set()); + } + this.trackLevelNodes.get(uuid).add(node); + } + + updateTrackLevelVisual(uuid, level) { + if (!uuid) { + return; + } + const nodes = this.trackLevelNodes.get(uuid); + if (!nodes || !nodes.size) { + return; + } + const normalized = Math.max(0.08, Math.min(1, (level || 0) / 100)); + nodes.forEach((node) => { + if (!node) { + return; + } + node.style.transform = `scaleY(${normalized})`; + node.style.opacity = level > 3 ? '0.95' : '0.45'; + }); + } + + captureParticipantMetrics(participant) { + if (!participant?.uuid) { + return; + } + const next = { ...(this.participantMetrics.get(participant.uuid) || {}) }; + if (typeof participant.audioBitrateKbps === 'number' && participant.audioBitrateKbps >= 0) { + next.audioBitrateKbps = participant.audioBitrateKbps; + } + if (participant.audioCodec) { + next.audioCodec = participant.audioCodec; + } + if (participant.external || participant.uuid === 'host-mic') { + next.local = true; + } + this.participantMetrics.set(participant.uuid, next); + this.updateTrackInboundMetric(participant.uuid, next); + } + + updateTrackInboundMetric(uuid, metrics = this.participantMetrics.get(uuid)) { + if (!uuid) { + return; + } + const resolvedMetrics = metrics || null; + const indicators = this.outputIndicators || new Map(); + indicators.forEach((indicator) => { + if (!indicator || !indicator.participant || indicator.participant.uuid !== uuid) { + return; + } + const node = indicator.inboundMetric; + if (!node) { + return; + } + if (resolvedMetrics?.local) { + node.textContent = 'Inbound: Local capture'; + return; + } + const parts = []; + if (resolvedMetrics && typeof resolvedMetrics.audioBitrateKbps === 'number' && resolvedMetrics.audioBitrateKbps > 0) { + const formatted = this.formatBitrate(resolvedMetrics.audioBitrateKbps); + if (formatted) { + parts.push(formatted); + } + } + if (resolvedMetrics?.audioCodec) { + parts.push(resolvedMetrics.audioCodec.toUpperCase()); + } + node.textContent = parts.length ? `Inbound: ${parts.join(' • ')}` : 'Inbound: pending…'; + }); + } + + updateRecordingRuntimeMetrics(key, indicator, detail) { + if (!indicator) { + return; + } + const runtime = this.trackRuntimeStats.get(key) || { + bytes: 0, + startedAt: Date.now(), + lastUpdate: Date.now(), + }; + const chunk = detail?.data; + if (chunk && typeof chunk.size === 'number') { + runtime.bytes += chunk.size; + } + const now = Date.now(); + if (!runtime.startedAt) { + runtime.startedAt = now; + } + runtime.lastUpdate = now; + const elapsedMs = Math.max(1, now - runtime.startedAt); + const kbps = runtime.bytes ? (runtime.bytes * 8) / elapsedMs : 0; + const durationSeconds = (now - runtime.startedAt) / 1000; + const sampleRate = this.recorder?.options?.targetSampleRate || 48000; + const sampleRateLabel = + sampleRate >= 1000 + ? `${(sampleRate / 1000).toFixed(sampleRate % 1000 === 0 ? 0 : 1)} kHz` + : `${sampleRate} Hz`; + const bitrateLabel = kbps > 0 ? `${Math.round(kbps)} kbps` : 'estimating…'; + const durationLabel = this.formatDuration(durationSeconds); + indicator.recordMetric.textContent = `Recording: ${bitrateLabel} • WAV ${sampleRateLabel} • ${durationLabel}`; + this.trackRuntimeStats.set(key, runtime); + } + + buildRecordingPlanContext({ diskInfo } = {}) { + const now = Date.now(); + const cloudSnapshot = readCloudLinkStatus(); + const plan = { + sessionId: createRecordingSessionId(), + conductor: 'studio', + preparedAt: now, + disk: { + enabled: Boolean(diskInfo?.ready), + folderName: diskInfo?.folderName || null, + verifiedAt: diskInfo?.verifiedAt || null, + }, + cloud: { + driveLinked: Boolean(this.cloud?.hasDriveAccess() || cloudSnapshot.drive), + dropboxLinked: Boolean(this.cloud?.hasDropboxAccess() || cloudSnapshot.dropbox), + snapshot: cloudSnapshot, + }, + sync: { + prepared: snapshotHighResClock(), + start: null, + stop: null, + }, + participants: {}, + files: [], + events: [], + }; + this.recordingPlan = plan; + this.recordingSessionId = plan.sessionId; + this.logRecordingEvent('record:plan', { sessionId: plan.sessionId }); + dispatchStudioEvent(PODCAST_RECORD_PLAN_EVENT, { plan }); + this.setRecordingStatus('Recording plan armed', 'armed'); + return plan; + } + + updateRecordingPlanStatus(status, extra = {}) { + if (!this.recordingPlan) { + return; + } + const detail = { + status, + plan: this.recordingPlan, + timestamp: Date.now(), + ...extra, + }; + dispatchStudioEvent(PODCAST_RECORD_STATUS_EVENT, detail); + } + + trackManifestChunk(detail) { + if (!this.recordingPlan || !detail?.participant?.uuid) { + return; + } + const participantId = detail.participant.uuid; + if (!this.recordingPlan.participants[participantId]) { + this.recordingPlan.participants[participantId] = { + participantId, + label: detail.participant.label || participantId, + tracks: {}, + }; + } + const participantPlan = this.recordingPlan.participants[participantId]; + const trackKey = `${detail.trackType || 'audio'}:${typeof detail.channelIndex === 'number' ? detail.channelIndex : 0}`; + if (!participantPlan.tracks[trackKey]) { + participantPlan.tracks[trackKey] = { + trackType: detail.trackType || 'audio', + channelIndex: typeof detail.channelIndex === 'number' ? detail.channelIndex : 0, + segments: [], + totalBytes: 0, + sequence: 0, + }; + } + const track = participantPlan.tracks[trackKey]; + const bytes = detail.data?.size || 0; + track.sequence += 1; + track.totalBytes += bytes; + const timecodeMs = this.recordStartedAt ? Date.now() - this.recordStartedAt : 0; + const segment = { + sequence: track.sequence, + bytes, + receivedAt: Date.now(), + timecodeMs, + }; + if (track.segments.length > 48) { + track.segments.shift(); + } + track.segments.push(segment); + } + + summariseRecordingFiles(filesMap) { + if (!filesMap || typeof filesMap.forEach !== 'function') { + return []; + } + const summaries = []; + filesMap.forEach((meta) => { + if (!meta) { + return; + } + summaries.push({ + participant: meta.participant?.uuid || null, + label: meta.participant?.label || null, + trackType: meta.trackType, + channelIndex: meta.channelIndex, + filename: meta.filename, + mimeType: meta.mimeType, + size: meta.size, + durationSeconds: meta.durationSeconds, + }); + }); + return summaries; + } + + logRecordingEvent(type, data = {}) { + if (!type) { + return; + } + if (!this.recordingPlan) { + this.recordingPlan = { + sessionId: createRecordingSessionId(), + events: [], + }; + } + if (!Array.isArray(this.recordingPlan.events)) { + this.recordingPlan.events = []; + } + const timestamp = Date.now(); + const timecodeMs = this.recordStartedAt ? Math.max(0, timestamp - this.recordStartedAt) : 0; + this.recordingPlan.events.push({ + type, + timestamp, + timecodeMs, + data, + }); + if (this.recordingPlan.events.length > 2000) { + this.recordingPlan.events.shift(); + } + } + + setRecordingStatus(text, state = 'idle') { + if (!this.recordingStatusNode) { + return; + } + this.recordingStatusNode.textContent = text; + this.recordingStatusNode.dataset.state = state; + } + + formatBitrate(value) { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + if (value >= 1000) { + const megabits = value / 1000; + return `${megabits.toFixed(megabits >= 10 ? 0 : 1)} Mbps`; + } + return `${Math.round(value)} kbps`; + } + + formatDuration(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0:00'; + } + const totalSeconds = Math.floor(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; + } + + async handleRecordToggle() { + if (this.recording) { + this.showOutputsMessage('Wrapping up recording…'); + this.logRecordingEvent('record:stop:requested', { reason: 'host-toggle' }); + this.setRecordingStatus('Stopping recording…', 'stopping'); + if (this.markerButton) { + this.markerButton.disabled = true; + } + if (this.autoMarkerTimeout) { + clearTimeout(this.autoMarkerTimeout); + this.autoMarkerTimeout = null; + } + const markerSnapshot = this.markers.map((marker) => ({ ...marker })); + try { + await this.recorder.stop({ markers: markerSnapshot }); + } catch (error) { + console.error('Failed to stop recorder cleanly', error); + this.setStatusMessage('Recording stop failed: ' + (error?.message || 'unknown error')); + } + return; + } + try { + let diskInfo = null; + if (STUDIO_DISK_FEATURE_FLAG && this.diskRecordingEnabled) { + diskInfo = await this.ensureDiskCaptureReadiness({ interactive: true }); + if (diskInfo && diskInfo.error) { + this.setStatusMessage(diskInfo.error.message || 'Disk folder not accessible.'); + return; + } + } + this.buildRecordingPlanContext({ diskInfo }); + this.logRecordingEvent('record:arm', { source: 'host-toggle' }); + this.updateRecordingPlanStatus('armed', { events: this.recordingPlan?.events || [] }); + this.setRecordingStatus('Arming recorders…', 'arming'); + await this.recorder.start({ + includeVideo: false, + includeLocal: false, + extraParticipants: this.getAdditionalRecordingParticipants(), + }); + } catch (error) { + console.error('Failed to start recorder', error); + this.setStatusMessage('Unable to start recording: ' + (error?.message || 'unknown error')); + this.updateHostMicUI(); + this.logRecordingEvent('record:error', { stage: 'start', message: error?.message || 'unknown error' }); + this.setRecordingStatus('Recording idle', 'error'); + this.updateRecordingPlanStatus('error', { error: error?.message || 'start failed', events: this.recordingPlan?.events || [] }); + this.setUploadProgressPending(false); + } + } + + tryAddParticipantToRecording(participant) { + if (!this.recording || !this.recorder) { + return; + } + if (!participant?.stream) { + return; + } + try { + const result = this.recorder.addParticipant(participant); + if (result?.added) { + console.log(`Added late-joining participant to recording: ${participant.label || participant.uuid} (offset: ${result.startOffsetSeconds?.toFixed(1)}s)`); + this.logRecordingEvent('participant:added-mid-recording', { + uuid: participant.uuid, + label: participant.label, + startOffsetSeconds: result.startOffsetSeconds, + trackCount: result.tracks, + }); + // Drop a sync marker so the new track can be aligned with existing tracks + this.addSyncMarkerForNewTrack(participant, result.startOffsetSeconds); + } + } catch (error) { + console.warn('Failed to add participant to recording', error); + } + } + + addSyncMarkerForNewTrack(participant, startOffsetSeconds) { + if (!this.recording || !this.recordStartedAt) { + return; + } + // Wait 1 second after track starts, then drop a sync marker + // This gives the track time to stabilize before the sync point + setTimeout(() => { + if (!this.recording) { + return; + } + const timestamp = this.recordStartedAt ? (Date.now() - this.recordStartedAt) / 1000 : startOffsetSeconds + 1; + const label = participant?.label || participant?.uuid || 'Guest'; + const note = { + time: timestamp, + label: `Sync: ${label} joined @ ${timestamp.toFixed(1)}s`, + auto: true, + joinSync: true, + }; + this.markers.push(note); + this.logRecordingEvent('marker', { label: note.label, timeSeconds: note.time, auto: true, joinSync: true }); + this.renderMarkers(); + }, 1000); + } + + openRecordShowWindow() { + const room = this.resolveRoomName(); + if (!room) { + this.setStatusMessage('Set a room name before recording the show.'); + return; + } + // Build URL to the main VDO.ninja with scene + recordwindow + const baseUrl = window.location.origin + window.location.pathname.replace(/\/podcast\/?.*/, ''); + const url = `${baseUrl}/?scene=0&room=${encodeURIComponent(room)}&recordwindow&chroma=000&locked=1.777`; + const win = window.open( + url, + 'recordShow', + 'toolbar=no,location=no,status=no,menubar=no,scrollbars=no,resizable=yes,width=1280,height=720' + ); + if (win) { + win.focus(); + } + } + + async presentRecordings(filesMap) { + const files = filesMap || this.recorder.getFiles(); + if (!files || files.size === 0) { + this.showOutputsMessage('No media captured.'); + this.setUploadProgressPending(false); + return; + } + this.cleanupDownloadUrls(); + this.outputsContainer.dataset.mode = 'results'; + this.outputsContainer.dataset.hasTracks = ''; + this.outputsContainer.classList.remove('timeline-tracklist'); + this.outputsContainer.classList.add('timeline-results'); + this.outputsContainer.innerHTML = ''; + const uploadPromises = []; + this.setUploadProgressPending(false); + files.forEach((meta, key) => { + if (!meta?.blob) { + return; + } + const wrapper = createElement('div', 'timeline-entry'); + wrapper.dataset.key = key; + const label = `${meta.trackType.toUpperCase()} • ${meta.participant.label || meta.participant.uuid}`; + const downloadUrl = URL.createObjectURL(meta.blob); + this.activeDownloadUrls.push(downloadUrl); + const linkLabel = meta.mimeType === 'audio/wav' ? 'Download WAV' : 'Download'; + const linkTitle = + meta.mimeType === 'audio/wav' + ? 'Download as WAV (includes embedded cue markers).' + : 'Download captured media.'; + const link = createElement('a', 'marker-badge', { text: linkLabel, href: downloadUrl, title: linkTitle }); + const fallbackExtension = meta.mimeType?.split('/')?.[1] || 'webm'; + link.download = meta.filename || `${meta.participant.streamID || meta.participant.uuid}-${meta.trackType}.${fallbackExtension}`; + const header = createElement('div', 'timeline-entry-header'); + header.append(createElement('span', 'timeline-entry-label', { text: label }), link); + wrapper.append(header); + const metaSummary = this.describeTrackMeta(meta) || 'Metadata pending'; + const metaLine = createElement('div', 'upload-meta', { text: metaSummary }); + if (meta.packagingError) { + metaLine.textContent += metaSummary ? ' • fallback export' : 'Fallback export'; + } + wrapper.append(metaLine); + const statusContainer = createElement('div', 'upload-status'); + const driveLine = this.createServiceStatusLine('drive'); + const dropboxLine = this.createServiceStatusLine('dropbox'); + statusContainer.append(driveLine, dropboxLine); + wrapper.append(statusContainer); + this.outputsContainer.append(wrapper); + uploadPromises.push( + this.queueCloudUpload(meta, { + drive: driveLine, + dropbox: dropboxLine, + }), + ); + }); + if (uploadPromises.length) { + try { + await Promise.allSettled(uploadPromises); + } catch (error) { + console.warn('One or more uploads failed', error); + } + } + this.updateCloudFooter(); + } + + addMarker() { + if (!this.recording) { + return; + } + const timestamp = this.recordStartedAt ? (Date.now() - this.recordStartedAt) / 1000 : 0; + const note = { + time: timestamp, + label: `Marker @ ${timestamp.toFixed(1)}s`, + }; + this.markers.push(note); + this.logRecordingEvent('marker', { label: note.label, timeSeconds: note.time }); + this.renderMarkers(); + } + + scheduleAutoSyncMarker() { + if (this.autoMarkerTimeout) { + return; + } + this.autoMarkerTimeout = setTimeout(() => { + this.autoMarkerTimeout = null; + if (!this.recording) { + return; + } + const timestamp = this.recordStartedAt ? (Date.now() - this.recordStartedAt) / 1000 : 1; + const note = { + time: timestamp, + label: `Auto sync @ ${timestamp.toFixed(1)}s`, + auto: true, + }; + this.markers.push(note); + this.logRecordingEvent('marker', { label: note.label, timeSeconds: note.time, auto: true }); + this.renderMarkers(); + }, 1000); + } + + escapeCsvValue(value) { + const raw = value === null || typeof value === 'undefined' ? '' : String(value); + const escaped = raw.replace(/\"/g, '""'); + return `"${escaped}"`; + } + + formatMarkerTimecode(seconds) { + const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; + const totalMs = Math.round(safeSeconds * 1000); + const hours = Math.floor(totalMs / 3600000); + const minutes = Math.floor((totalMs % 3600000) / 60000); + const secs = Math.floor((totalMs % 60000) / 1000); + const ms = totalMs % 1000; + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`; + } + + buildMarkersCsv() { + const markers = Array.isArray(this.markers) ? this.markers : []; + const header = ['index', 'time_seconds', 'timecode', 'label', 'auto'].join(','); + if (!markers.length) { + return `${header}\n`; + } + const rows = markers.map((marker, index) => { + const timeSeconds = Number.isFinite(marker?.time) ? marker.time : 0; + const timecode = this.formatMarkerTimecode(timeSeconds); + const label = marker?.label || `Marker #${index + 1}`; + const auto = marker?.auto ? '1' : '0'; + return [ + index + 1, + timeSeconds.toFixed(3), + this.escapeCsvValue(timecode), + this.escapeCsvValue(label), + auto, + ].join(','); + }); + return `${header}\n${rows.join('\n')}\n`; + } + + buildMarkersFilename() { + const sessionId = this.recordingSessionId || this.recordingPlan?.sessionId || 'session'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `vdo-ninja-markers-${sessionId}-${timestamp}.csv`; + } + + exportMarkersCsv() { + if (!this.markerExportButton) { + return; + } + const csv = this.buildMarkersCsv(); + if (!csv.trim()) { + return; + } + // Visual feedback while preparing download + const originalText = this.markerExportButton.textContent; + this.markerExportButton.textContent = 'Exporting…'; + this.markerExportButton.disabled = true; + + // Small delay to show feedback before download triggers + setTimeout(() => { + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + try { + const link = document.createElement('a'); + link.href = url; + link.download = this.buildMarkersFilename(); + link.rel = 'noopener'; + link.click(); + this.markerExportButton.textContent = 'Exported'; + } catch (error) { + console.warn('Failed to trigger CSV download', error); + this.markerExportButton.textContent = 'Export failed'; + } finally { + setTimeout(() => URL.revokeObjectURL(url), 100); + // Restore button after a moment + setTimeout(() => { + if (this.markerExportButton) { + this.markerExportButton.textContent = originalText; + this.markerExportButton.disabled = false; + } + }, 1500); + } + }, 50); + } + + async copyMarkersCsv() { + if (!this.markerCopyButton) { + return; + } + const csv = this.buildMarkersCsv(); + if (!csv.trim()) { + return; + } + try { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(csv); + } else { + const textarea = document.createElement('textarea'); + textarea.value = csv; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.append(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + this.markerCopyButton.textContent = 'Copied'; + if (this.markerCopyResetTimer) { + clearTimeout(this.markerCopyResetTimer); + } + this.markerCopyResetTimer = setTimeout(() => { + this.markerCopyResetTimer = null; + if (this.markerCopyButton) { + this.markerCopyButton.textContent = 'Copy CSV'; + } + }, 1500); + } catch (error) { + console.warn('Copy markers failed', error); + this.markerCopyButton.textContent = 'Copy failed'; + if (this.markerCopyResetTimer) { + clearTimeout(this.markerCopyResetTimer); + } + this.markerCopyResetTimer = setTimeout(() => { + this.markerCopyResetTimer = null; + if (this.markerCopyButton) { + this.markerCopyButton.textContent = 'Copy CSV'; + } + }, 2000); + } + } + + renderMarkers() { + this.markerLog.innerHTML = ''; + if (this.markerActions) { + this.markerActions.style.display = this.markers.length ? '' : 'none'; + } + if (!this.markers.length) { + const empty = createElement('div', 'empty-state', { text: 'Tap “Marker” to drop cue points during recording.' }); + empty.dataset.empty = 'true'; + this.markerLog.append(empty); + return; + } + // Render newest markers first (reverse order) so they appear at the top + for (let i = this.markers.length - 1; i >= 0; i -= 1) { + const marker = this.markers[i]; + const timeSeconds = Number.isFinite(marker?.time) ? marker.time : 0; + const timecode = this.formatMarkerTimecode(timeSeconds); + const item = createElement('div', 'marker-item'); + item.title = `${marker?.auto ? 'Auto sync' : 'Marker'} @ ${timecode}`; + item.append(createElement('span', '', { text: marker.label })); + item.append(createElement('span', 'marker-badge', { text: `#${i + 1}` })); + this.markerLog.append(item); + } + } + + startRosterLoop() { + if (this.rosterTimer) { + clearInterval(this.rosterTimer); + } + this.rosterTimer = setInterval(() => this.refreshRoster(), ROSTER_REFRESH_MS); + } + + refreshRoster() { + if (!this.session) { + return; + } + this.updateRoomIndicator(); + const baseParticipants = collectParticipants(this.session); + const participants = [...baseParticipants]; + this.virtualParticipants.forEach((participant) => { + if (participant) { + participants.push(participant); + } + }); + const activeIds = new Set(); + + participants.forEach((participant) => { + activeIds.add(participant.uuid); + this.captureParticipantMetrics(participant); + const existing = this.rosterItems.get(participant.uuid); + if (existing) { + this.updateRosterItem(existing, participant); + } else { + const item = this.createRosterItem(participant); + this.rosterItems.set(participant.uuid, item); + this.rosterList.append(item); + // Add new participant to active recording + this.tryAddParticipantToRecording(participant); + } + }); + + Array.from(this.rosterItems.keys()).forEach((uuid) => { + if (!activeIds.has(uuid)) { + const node = this.rosterItems.get(uuid); + if (node?.parentNode) { + node.parentNode.removeChild(node); + } + this.rosterItems.delete(uuid); + this.meterValues.delete(uuid); + this.teardownDriveControl(uuid); + if (this.remoteOverlay && this.remoteOverlay.dataset.activeUuid === uuid) { + this.closeRemoteOverlay(); + } + } + }); + } + + createRosterItem(participant) { + const item = createElement('div', 'roster-item'); + item.dataset.uuid = participant.uuid; + item.dataset.status = participant.status || 'connecting'; + if (participant.role) { + item.dataset.role = participant.role; + } + + // Video thumbnail for guest preview + const videoThumb = document.createElement('video'); + videoThumb.className = 'roster-item__video-thumb'; + videoThumb.muted = true; + videoThumb.playsInline = true; + videoThumb.autoplay = true; + videoThumb.dataset.noVideo = 'true'; // hidden by default until video track available + + const meta = createElement('div', 'roster-meta'); + meta.append(createElement('div', 'roster-name', { text: participant.label })); + const idText = participant.streamID ? `Stream: ${participant.streamID}` : 'Awaiting stream'; + meta.append(createElement('div', 'roster-id', { text: idText })); + const descriptorText = this.describeParticipantRole(participant); + if (descriptorText) { + meta.append(createElement('div', 'roster-role', { text: descriptorText })); + } + + const meter = createElement('div', 'meter-bar', { 'data-meter': participant.uuid }); + meter.append(createElement('div', 'meter-bar-fill')); + + const mediaRow = createElement('div', 'roster-item__media-row'); + mediaRow.append(videoThumb, meter); + + item.append(meta, mediaRow); + + const actions = createElement('div', 'roster-actions'); + const actionRow = createElement('div', 'roster-action-row'); + let hasActions = false; + if (participant.role !== 'host-mic') { + const controlButton = createElement('button', 'roster-action-button', { + type: 'button', + text: 'Remote Controls', + title: 'Open legacy remote controls for this guest.', + }); + controlButton.addEventListener('click', () => this.openRemoteControls(participant.uuid)); + actionRow.append(controlButton); + hasActions = true; + } + const driveControls = this.createDriveControl(participant); + if (driveControls) { + actionRow.append(driveControls.button); + hasActions = true; + } + if (hasActions) { + if (driveControls?.status) { + actionRow.append(driveControls.status); + } + actions.append(actionRow); + item.append(actions); + } + + this.updateRosterItem(item, participant); + return item; + } + + updateRosterItem(item, participant) { + item.dataset.status = participant.status || 'connecting'; + const name = item.querySelector('.roster-name'); + if (name) { + name.textContent = participant.label; + } + const id = item.querySelector('.roster-id'); + if (id) { + id.textContent = participant.streamID ? `Stream: ${participant.streamID}` : 'Awaiting stream'; + } + item.dataset.role = participant.role || ''; + const descriptor = item.querySelector('.roster-role'); + if (descriptor) { + const descriptorText = this.describeParticipantRole(participant); + descriptor.textContent = descriptorText || ''; + descriptor.style.display = descriptorText ? '' : 'none'; + } + this.applyMeterValue(participant.uuid, participant.audioLevel || 0); + this.updateDriveActionAvailability(participant.uuid); + + // Update video thumbnail if available + const videoThumb = item.querySelector('.roster-item__video-thumb'); + if (videoThumb && this.session?.rpcs) { + const peer = this.session.rpcs[participant.uuid]; + const videoTracks = peer?.streamSrc?.getVideoTracks?.() || []; + if (videoTracks.length > 0) { + if (!videoThumb.srcObject || videoThumb.srcObject.getVideoTracks()[0]?.id !== videoTracks[0].id) { + videoThumb.srcObject = new MediaStream(videoTracks); + } + videoThumb.dataset.noVideo = 'false'; + } else { + if (videoThumb.srcObject) { + videoThumb.srcObject = null; + } + videoThumb.dataset.noVideo = 'true'; + } + } + } + + createDriveControl(participant) { + if (!participant || participant.role === 'host-mic' || !participant.uuid) { + return null; + } + if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { + return null; + } + const button = createElement('button', 'roster-action-button roster-action-button--drive', { + type: 'button', + text: 'Guest → Drive', + }); + button.dataset.uuid = participant.uuid; + button.addEventListener('click', () => this.handleDriveRecordToggle(participant.uuid)); + + const status = createElement('div', 'roster-drive-status', { text: DRIVE_STATUS_MESSAGES.idle }); + status.dataset.state = 'idle'; + status.dataset.uuid = participant.uuid; + + this.rosterDriveButtons.set(participant.uuid, button); + this.rosterDriveStatuses.set(participant.uuid, status); + this.updateDriveActionAvailability(participant.uuid); + this.applyDriveSnapshot(participant.uuid); + + return { button, status }; + } + + teardownDriveControl(uuid) { + if (!uuid) { + return; + } + if (this.driveStatusResetTimers.has(uuid)) { + clearTimeout(this.driveStatusResetTimers.get(uuid)); + this.driveStatusResetTimers.delete(uuid); + } + this.rosterDriveButtons.delete(uuid); + this.rosterDriveStatuses.delete(uuid); + this.driveProgressSnapshots.delete(uuid); + } + + findLegacyDriveButton(uuid) { + if (!uuid || typeof document === 'undefined') { + return null; + } + return document.querySelector('[data-action-type="recorder-google-drive-remote"][data--u-u-i-d="' + uuid + '"]'); + } + + canTriggerDriveUpload() { + if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { + return false; + } + return Boolean(this.cloud?.hasDriveAccess()); + } + + async handleDriveRecordToggle(uuid) { + if (!uuid) { + return; + } + const button = this.rosterDriveButtons.get(uuid); + if (!button) { + return; + } + if (typeof window === 'undefined' || typeof window.requestGoogleDriveRecord !== 'function') { + this.setRosterDriveStatus(uuid, 'error', 'Drive controls unavailable in this build.'); + return; + } + const legacyButton = this.findLegacyDriveButton(uuid); + if (!legacyButton) { + this.setRosterDriveStatus(uuid, 'pending', 'Guest controls preparing…'); + this.updateDriveActionAvailability(uuid); + return; + } + const isActive = legacyButton.classList?.contains('pressed'); + if (!isActive && !this.canTriggerDriveUpload()) { + this.setRosterDriveStatus(uuid, 'error', 'Link Google Drive above to enable uploads.'); + this.updateDriveActionAvailability(uuid); + return; + } + button.dataset.pending = 'true'; + button.disabled = true; + try { + if (isActive) { + await window.requestGoogleDriveRecord(legacyButton, false); + this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); + } else { + this.setRosterDriveStatus(uuid, 'pending', 'Requesting Drive upload…'); + await window.requestGoogleDriveRecord(legacyButton); + } + } catch (error) { + const message = error?.message || 'Drive request cancelled'; + this.setRosterDriveStatus(uuid, 'error', message); + } finally { + button.dataset.pending = 'false'; + this.updateDriveActionAvailability(uuid); + } + } + + updateDriveActionAvailability(uuid) { + const button = this.rosterDriveButtons.get(uuid); + if (!button) { + return; + } + const hasRequestApi = typeof window !== 'undefined' && typeof window.requestGoogleDriveRecord === 'function'; + const legacyButton = this.findLegacyDriveButton(uuid); + const hasLegacyControl = Boolean(legacyButton); + const isActive = Boolean(legacyButton?.classList?.contains('pressed')); + const pending = button.dataset.pending === 'true'; + + let disabled = pending || !hasRequestApi; + let title = ''; + + if (!hasRequestApi) { + title = 'Drive controls are not available in this build.'; + } else if (!hasLegacyControl) { + title = 'Guest controls are still initialising.'; + disabled = true; + } else if (isActive) { + title = 'Stop this guest’s Drive upload.'; + disabled = pending; + } else if (!this.canTriggerDriveUpload()) { + title = 'Link Google Drive above to enable uploads.'; + disabled = true; + } else { + title = 'Ask this guest to upload to Drive.'; + disabled = pending; + } + + button.disabled = disabled; + button.textContent = isActive ? 'Stop Guest → Drive' : 'Guest → Drive'; + button.dataset.state = isActive ? 'active' : 'idle'; + if (title) { + button.title = title; + } + } + + updateAllDriveActions() { + this.rosterDriveButtons.forEach((_, uuid) => this.updateDriveActionAvailability(uuid)); + } + + setRosterDriveStatus(uuid, state = 'idle', text) { + const node = this.rosterDriveStatuses.get(uuid); + if (!node) { + return; + } + if (this.driveStatusResetTimers.has(uuid)) { + clearTimeout(this.driveStatusResetTimers.get(uuid)); + this.driveStatusResetTimers.delete(uuid); + } + const label = text || DRIVE_STATUS_MESSAGES[state] || DRIVE_STATUS_MESSAGES.idle; + node.dataset.state = state; + node.textContent = label; + if (state === 'done') { + const timer = setTimeout(() => { + this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); + this.driveStatusResetTimers.delete(uuid); + }, DRIVE_STATUS_RESET_MS); + this.driveStatusResetTimers.set(uuid, timer); + } + } + + applyDriveSnapshot(uuid) { + const snapshot = this.driveProgressSnapshots.get(uuid); + if (!snapshot) { + return; + } + this.setRosterDriveStatusFromSnapshot(uuid, snapshot); + } + + setRosterDriveStatusFromSnapshot(uuid, gdrive) { + if (!gdrive) { + this.setRosterDriveStatus(uuid, 'idle', DRIVE_STATUS_MESSAGES.idle); + return; + } + if (gdrive.state === 2) { + this.setRosterDriveStatus(uuid, 'done', DRIVE_STATUS_MESSAGES.done); + return; + } + if (typeof gdrive.rec === 'number' && gdrive.rec > 0) { + const percent = Math.min(100, Math.round((gdrive.up / Math.max(1, gdrive.rec)) * 100)); + this.setRosterDriveStatus(uuid, 'uploading', `Drive upload ${percent}%`); + } else { + this.setRosterDriveStatus(uuid, 'pending', DRIVE_STATUS_MESSAGES.pending); + } + } + + handleDriveProgressEvent(event) { + const detail = event?.detail; + if (!detail || !detail.UUID) { + return; + } + const { UUID: uuid, gdrive } = detail; + this.driveProgressSnapshots.set(uuid, gdrive || null); + if (!this.rosterDriveStatuses.has(uuid)) { + return; + } + this.setRosterDriveStatusFromSnapshot(uuid, gdrive || null); + this.updateDriveActionAvailability(uuid); + } + + ensureRemoteOverlay() { + if (this.remoteOverlay && this.remoteOverlayContent) { + return this.remoteOverlay; + } + const overlay = createElement('div', 'remote-overlay'); + overlay.dataset.podcastOverlay = 'true'; + overlay.dataset.visible = 'false'; + + const panel = createElement('div', 'remote-overlay__panel'); + const header = createElement('div', 'remote-overlay__header'); + const title = createElement('h3', 'remote-overlay__title', { text: 'Remote controls' }); + const closeButton = createElement('button', 'remote-overlay__close', { type: 'button', text: 'Close', title: 'Close remote controls.' }); + closeButton.addEventListener('click', () => this.closeRemoteOverlay()); + header.append(title, closeButton); + + const body = createElement('div', 'remote-overlay__body'); + panel.append(header, body); + overlay.append(panel); + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + this.closeRemoteOverlay(); + } + }); + + document.body.appendChild(overlay); + this.remoteOverlay = overlay; + this.remoteOverlayContent = body; + return overlay; + } + + restoreRemoteControls() { + const state = this.remoteControlState; + if (!state || !state.element) { + if (this.remoteOverlay) { + delete this.remoteOverlay.dataset.activeUuid; + } + return; + } + const { element, placeholder, wrapper } = state; + try { + if (wrapper && wrapper.parentNode) { + wrapper.parentNode.removeChild(wrapper); + } + } catch (error) { + console.warn('Failed to remove remote controls wrapper', error); + } + if (placeholder && placeholder.parentNode) { + try { + placeholder.parentNode.insertBefore(element, placeholder); + placeholder.parentNode.removeChild(placeholder); + } catch (error) { + console.warn('Failed to restore remote controls container', error); + } + } + this.remoteControlState = { + activeUuid: null, + element: null, + placeholder: null, + wrapper: null, + }; + if (this.remoteOverlay) { + delete this.remoteOverlay.dataset.activeUuid; + } + } + + openRemoteControls(uuid) { + if (!uuid) { + return; + } + if (this.remoteControlState?.activeUuid && this.remoteControlState.activeUuid !== uuid) { + this.restoreRemoteControls(); + } + const overlay = this.ensureRemoteOverlay(); + const body = this.remoteOverlayContent; + if (!overlay || !body) { + return; + } + body.innerHTML = ''; + + const rosterNode = this.rosterItems.get(uuid); + let label = ''; + if (rosterNode) { + const nameNode = rosterNode.querySelector('.roster-name'); + label = nameNode ? nameNode.textContent : ''; + } + const headerTitle = overlay.querySelector('.remote-overlay__title'); + if (headerTitle) { + headerTitle.textContent = label ? `Remote controls • ${label}` : 'Remote controls'; + } + + const existingState = this.remoteControlState || {}; + if (existingState.activeUuid && existingState.activeUuid === uuid && existingState.wrapper) { + body.append(existingState.wrapper); + overlay.dataset.visible = 'true'; + overlay.dataset.activeUuid = uuid; + return; + } + + const source = document.getElementById(`container_${uuid}`); + if (!source) { + body.append( + createElement('div', 'remote-overlay__empty', { + text: 'Legacy director controls are still loading. Try again once the guest is fully connected.', + }), + ); + overlay.dataset.visible = 'true'; + overlay.dataset.activeUuid = uuid; + return; + } + + const placeholder = document.createElement('div'); + placeholder.dataset.podcastPlaceholder = 'remote-controls'; + source.parentNode?.insertBefore(placeholder, source); + + source.classList.remove('hidden'); + + const wrapper = createElement('div', 'remote-overlay__legacy'); + wrapper.dataset.uuid = uuid; + wrapper.append(source); + body.append(wrapper); + + this.remoteControlState = { + activeUuid: uuid, + element: source, + placeholder, + wrapper, + }; + + overlay.dataset.visible = 'true'; + overlay.dataset.activeUuid = uuid; + } + + closeRemoteOverlay() { + if (!this.remoteOverlay) { + return; + } + this.restoreRemoteControls(); + this.remoteOverlay.dataset.visible = 'false'; + if (this.remoteOverlayContent) { + this.remoteOverlayContent.innerHTML = ''; + } + } + + openHelpModal() { + if (this.helpOverlay) { + this.helpOverlay.dataset.visible = 'true'; + return; + } + + const overlay = createElement('div', 'help-overlay'); + overlay.dataset.visible = 'true'; + overlay.dataset.podcastOverlay = 'true'; // Prevent CSS from hiding it + this.helpOverlay = overlay; + + const panel = createElement('div', 'help-overlay__panel'); + + const header = createElement('div', 'help-overlay__header'); + const title = createElement('h2', 'help-overlay__title', { text: 'Podcast Studio Guide' }); + const closeButton = createElement('button', 'help-overlay__close', { type: 'button', text: '✕', title: 'Close' }); + closeButton.addEventListener('click', () => this.closeHelpModal()); + header.append(title, closeButton); + + const content = createElement('div', 'help-overlay__content'); + + const sections = [ + { + title: 'Getting Started', + content: ` +

The Podcast Studio is a specialized interface for recording multi-track audio from remote guests.

+ +

All audio is recorded locally in your browser — nothing is uploaded unless you link cloud storage.

+ `, + }, + { + title: 'Session Markers', + content: ` +

Markers are cue points you can drop during recording to mark important moments.

+ +

WAV cue points: Markers are embedded directly in the WAV files as standard cue chunks. Compatible with:

+ +

CSV export: Use "Export CSV" or "Copy CSV" to get markers in spreadsheet format for reference or importing into editors that don't read WAV cues.

+ `, + }, + { + title: 'Late Joiners & Reconnects', + content: ` +

If a guest joins or reconnects while recording is in progress:

+ +

Syncing in post: Each track's markers are adjusted relative to when that track started. Use the shared sync markers to align tracks in your editor.

+

Screen shares added mid-session are also captured if video recording is enabled.

+ `, + }, + { + title: 'Cloud Backup', + content: ` +

Link Google Drive or Dropbox to automatically upload recordings.

+

Google Drive:

+ +

Dropbox:

+ +

Both services are optional — recordings are always available for local download.

+ `, + }, + { + title: 'Video Recording', + content: ` +

The studio focuses on audio ISO recording, but video options exist:

+ +

For individual video tracks, guests can use &record in their URL to self-record, or use the remote recording features in the classic VDO.Ninja interface.

+ `, + }, + { + title: 'Live Captions', + content: ` +

VDO.Ninja supports real-time speech-to-text captions:

+ +

Captions are processed locally in the browser using the Web Speech API — no third-party services required.

+ `, + }, + { + title: 'Tips & Troubleshooting', + content: ` + + `, + }, + ]; + + sections.forEach((section) => { + const item = createElement('details', 'help-section'); + const summary = createElement('summary', 'help-section__title', { text: section.title }); + const body = createElement('div', 'help-section__body'); + body.innerHTML = section.content; + item.append(summary, body); + content.append(item); + }); + + // Open first section by default + const firstSection = content.querySelector('details'); + if (firstSection) { + firstSection.open = true; + } + + panel.append(header, content); + overlay.append(panel); + + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + this.closeHelpModal(); + } + }); + + document.body.appendChild(overlay); + } + + closeHelpModal() { + if (this.helpOverlay) { + this.helpOverlay.dataset.visible = 'false'; + } + } + + describeParticipantRole(participant) { + if (!participant) { + return ''; + } + if (participant.role === 'host-mic') { + return 'Local recording input'; + } + return ''; + } + + applyMeterValue(uuid, value) { + const percent = Math.min(100, Math.max(0, value)); + this.meterValues.set(uuid, percent); + const meter = this.rosterList.querySelector(`[data-meter="${uuid}"] .meter-bar-fill`); + if (meter) { + meter.style.width = `${percent}%`; + } + } + + updateMeterFromBus(payload) { + if (!payload?.uuid) { + return; + } + const peak = payload.peak || 0; + const level = Math.min(100, Math.round(peak * 120)); + this.applyMeterValue(payload.uuid, level); + this.updateTrackLevelVisual(payload.uuid, level); + } + + updateCloudFooter() { + if (this.driveStatusNode) { + const driveText = this.cloud?.hasDriveAccess() + ? 'Google Drive linked' + : 'Drive link pending'; + this.driveStatusNode.textContent = driveText; + } + if (this.dropboxStatusNode) { + const dropboxText = this.cloud?.hasDropboxAccess() + ? 'Dropbox linked' + : 'Dropbox link pending'; + this.dropboxStatusNode.textContent = dropboxText; + } + this.updateCloudLinkUI(); + this.updateReadinessSummary(); + } + + updateReadinessSummary() { + if (this.cloudSummaryNode) { + const driveActive = Boolean(this.cloud?.hasDriveAccess()); + const dropboxActive = Boolean(this.cloud?.hasDropboxAccess()); + const driveStatus = driveActive ? 'Drive ready' : 'Drive not linked'; + const dropboxStatus = dropboxActive ? 'Dropbox ready' : 'Dropbox not linked'; + this.cloudSummaryNode.textContent = `Cloud uploads: ${driveStatus} • ${dropboxStatus}`; + this.cloudSummaryNode.dataset.state = driveActive || dropboxActive ? 'ready' : 'pending'; + } + } + + formatFileSize(bytes) { + if (!bytes && bytes !== 0) { + return ''; + } + const thresh = 1024; + if (bytes < thresh) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB', 'TB']; + let unitIndex = -1; + let value = bytes; + do { + value /= thresh; + unitIndex += 1; + } while (value >= thresh && unitIndex < units.length - 1); + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + describeTrackMeta(meta) { + const parts = []; + if (meta?.mimeType) { + parts.push(meta.mimeType.toUpperCase()); + } + if (meta?.size) { + parts.push(this.formatFileSize(meta.size)); + } + if (meta?.durationSeconds) { + parts.push(`${meta.durationSeconds.toFixed(1)}s`); + } + return parts.join(' • '); + } + + describeService(service) { + if (service === 'drive') { + return 'Drive'; + } + if (service === 'dropbox') { + return 'Dropbox'; + } + return service || 'Service'; + } + + createServiceStatusLine(service) { + const line = createElement('div', 'upload-status-line'); + line.dataset.service = service; + const ready = + service === 'drive' + ? this.cloud?.hasDriveAccess() + : service === 'dropbox' + ? this.cloud?.hasDropboxAccess() + : false; + const hint = ready ? 'ready' : 'link to upload'; + line.textContent = `${this.describeService(service)}: ${hint}`; + line.title = ready + ? `${this.describeService(service)} is linked; uploads will start when queued.` + : `Link ${this.describeService(service)} above to enable uploads.`; + return line; + } + + setUploadProgressPending(pending) { + ['drive', 'dropbox'].forEach((service) => { + const node = this.cloudProgressNodes?.[service]; + if (!node) { + return; + } + if (pending) { + node.dataset.state = 'pending'; + node.textContent = `${this.describeService(service)} uploads pending (recording in progress)`; + } else if (!this.uploadTrackers?.[service]?.size) { + node.dataset.state = 'idle'; + node.textContent = `${this.describeService(service)} uploads idle`; + } + }); + } + + registerUploadTask(service, meta) { + if (!service || !this.uploadTrackers?.[service]) { + return null; + } + const tracker = this.uploadTrackers[service]; + const key = `${service}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const bytesTotal = meta?.blob?.size || 0; + tracker.set(key, { + key, + label: meta?.participant?.label || meta?.filename || 'Track', + bytesUploaded: 0, + bytesTotal, + status: 'pending', + startedAt: Date.now(), + }); + this.refreshUploadProgress(service); + return key; + } + + updateUploadTask(service, key, { uploaded, total, status } = {}) { + if (!service || !key || !this.uploadTrackers?.[service]) { + return; + } + const tracker = this.uploadTrackers[service]; + const entry = tracker.get(key); + if (!entry) { + return; + } + if (typeof uploaded === 'number') { + entry.bytesUploaded = uploaded; + } + if (typeof total === 'number' && total >= 0) { + entry.bytesTotal = total; + } + if (status) { + entry.status = status; + } + this.refreshUploadProgress(service); + } + + finalizeUploadTask(service, key, status = 'uploaded') { + if (!service || !key || !this.uploadTrackers?.[service]) { + return; + } + const tracker = this.uploadTrackers[service]; + const entry = tracker.get(key); + if (!entry) { + return; + } + entry.status = status; + if (!entry.bytesTotal) { + entry.bytesTotal = entry.bytesUploaded; + } + tracker.set(key, entry); + this.refreshUploadProgress(service); + const ttl = status === 'error' ? UPLOAD_TRACKER_COOLDOWN_MS * 2 : UPLOAD_TRACKER_COOLDOWN_MS; + setTimeout(() => { + const current = tracker.get(key); + if (current && current.status === status) { + tracker.delete(key); + this.refreshUploadProgress(service); + } + }, ttl); + } + + refreshUploadProgress(service) { + const node = this.cloudProgressNodes?.[service]; + const tracker = this.uploadTrackers?.[service]; + if (!node || !tracker) { + return; + } + if (!tracker.size) { + node.textContent = `${this.describeService(service)} uploads idle`; + node.dataset.state = 'idle'; + return; + } + const entries = Array.from(tracker.values()); + const errors = entries.filter((entry) => entry.status === 'error'); + const active = entries.filter((entry) => entry.status === 'pending' || entry.status === 'uploading'); + const completed = entries.filter((entry) => entry.status === 'uploaded'); + const skipped = entries.filter((entry) => entry.status === 'skipped'); + const uploadedBytes = entries.reduce((total, entry) => total + Math.min(entry.bytesUploaded || 0, entry.bytesTotal || entry.bytesUploaded || 0), 0); + const totalBytes = entries.reduce((total, entry) => total + (entry.bytesTotal || entry.bytesUploaded || 0), 0); + const percentage = totalBytes ? Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)) : 0; + if (errors.length) { + node.textContent = `${this.describeService(service)} upload error (${errors.length})`; + node.dataset.state = 'error'; + return; + } + if (active.length) { + node.textContent = `${this.describeService(service)} uploading ${active.length} file${active.length === 1 ? '' : 's'} • ${percentage}%`; + node.dataset.state = 'uploading'; + return; + } + if (completed.length || skipped.length) { + node.textContent = `${this.describeService(service)} uploads complete`; + node.dataset.state = 'complete'; + return; + } + node.textContent = `${this.describeService(service)} uploads idle`; + node.dataset.state = 'idle'; + } + + applyUploadResult(element, result) { + if (!element || !result) { + return; + } + const label = this.describeService(result.service || element.dataset.service); + element.dataset.status = result.status || 'unknown'; + if (result.status === 'uploaded') { + const sizeText = result.bytes ? ` (${this.formatFileSize(result.bytes)})` : ''; + element.textContent = `${label}: uploaded${sizeText}`; + } else if (result.status === 'skipped') { + element.textContent = `${label}: ${result.reason || 'skipped'}`; + } else if (result.status === 'error') { + const message = result.error?.message || result.error?.toString() || 'failed'; + element.textContent = `${label}: ${message}`; + element.dataset.status = 'error'; + } else { + element.textContent = `${label}: ${result.status || 'unknown'}`; + } + } + + enqueuePendingDriveUpload(meta, driveElement) { + if (!meta || !meta.blob) { + return; + } + const existing = this.pendingDriveUploads.find((entry) => entry.meta === meta); + if (existing) { + if (driveElement) { + existing.driveElement = driveElement; + } + return; + } + this.pendingDriveUploads.push({ + meta, + driveElement: driveElement || null, + }); + if (driveElement) { + driveElement.dataset.status = 'pending'; + driveElement.textContent = `${this.describeService('drive')}: waiting for link…`; + } + } + + async flushPendingDriveUploads() { + if (!this.pendingDriveUploads.length || !this.cloud?.hasDriveAccess()) { + return; + } + const pending = [...this.pendingDriveUploads]; + this.pendingDriveUploads = []; + for (const entry of pending) { + try { + await this.queueCloudUpload( + entry.meta, + { + drive: entry.driveElement, + }, + { driveOnly: true }, + ); + } catch (error) { + console.warn('Deferred Drive upload failed', error); + } + } + } + + async queueCloudUpload(meta, serviceElements = {}, options = {}) { + if (!this.cloud || !meta?.blob) { + if (serviceElements?.drive) { + serviceElements.drive.textContent = 'Drive: unavailable'; + } + if (serviceElements?.dropbox) { + serviceElements.dropbox.textContent = 'Dropbox: unavailable'; + } + return; + } + + const driveOnly = options.driveOnly === true; + + let driveClient = null; + let driveReady = false; + try { + driveClient = this.cloud.ensureDriveClient(); + driveReady = Boolean(this.cloud?.hasDriveAccess()); + } catch (error) { + console.warn('Drive client unavailable; continuing without Drive uploads', error); + } + let canDropbox = !driveOnly && Boolean(this.cloud?.hasDropboxAccess()); + if (!driveOnly && !canDropbox) { + try { + const dropboxClient = await this.cloud.ensureDropboxClient(); + canDropbox = Boolean(dropboxClient); + } catch (error) { + console.warn('Dropbox client unavailable; continuing without Dropbox uploads', error); + } + } + + if (serviceElements?.drive) { + const driveHint = driveReady ? 'preparing upload…' : 'link to upload'; + serviceElements.drive.textContent = `${this.describeService('drive')}: ${driveHint}`; + serviceElements.drive.dataset.status = driveReady ? 'pending' : 'idle'; + } + if (!driveOnly && serviceElements?.dropbox) { + serviceElements.dropbox.textContent = `${this.describeService('dropbox')}: ${canDropbox ? 'preparing upload…' : 'link to upload'}`; + serviceElements.dropbox.dataset.status = canDropbox ? 'pending' : 'idle'; + } + + const uploadKeys = {}; + const allowDriveUpload = driveReady && Boolean(driveClient); + if (allowDriveUpload) { + uploadKeys.drive = this.registerUploadTask('drive', meta); + } else if (!driveOnly && driveClient) { + this.enqueuePendingDriveUpload(meta, serviceElements?.drive || null); + } + if (!driveOnly && canDropbox) { + uploadKeys.dropbox = this.registerUploadTask('dropbox', meta); + } + + try { + const results = await this.cloud.uploadBlob(meta.blob, { + filename: meta.filename, + drive: allowDriveUpload, + dropbox: !driveOnly && canDropbox, + onProgress: (progress) => { + if (!progress?.service) { + return; + } + const label = this.describeService(progress.service); + if (progress.service === 'drive' && serviceElements?.drive) { + serviceElements.drive.textContent = `${label}: ${progress.percentage || 0}%`; + if (uploadKeys.drive) { + this.updateUploadTask('drive', uploadKeys.drive, { + uploaded: progress.uploaded, + total: progress.total, + status: 'uploading', + }); + } + } else if (progress.service === 'dropbox' && serviceElements?.dropbox) { + serviceElements.dropbox.textContent = `${label}: ${progress.percentage || 0}%`; + if (uploadKeys.dropbox) { + this.updateUploadTask('dropbox', uploadKeys.dropbox, { + uploaded: progress.uploaded, + total: progress.total, + status: 'uploading', + }); + } + } + }, + signal: this.abortUploadsController?.signal, + }); + if (allowDriveUpload) { + this.applyUploadResult(serviceElements?.drive, results.drive); + } + if (!driveOnly) { + this.applyUploadResult(serviceElements?.dropbox, results.dropbox); + } + if (uploadKeys.drive) { + this.finalizeUploadTask('drive', uploadKeys.drive, results.drive?.status || 'unknown'); + } + if (uploadKeys.dropbox) { + this.finalizeUploadTask('dropbox', uploadKeys.dropbox, results.dropbox?.status || 'unknown'); + } + } catch (error) { + console.error('Cloud upload failed', error); + if (allowDriveUpload && serviceElements?.drive) { + serviceElements.drive.textContent = 'Drive: upload failed'; + serviceElements.drive.dataset.status = 'error'; + } + if (!driveOnly && serviceElements?.dropbox) { + serviceElements.dropbox.textContent = 'Dropbox: upload failed'; + serviceElements.dropbox.dataset.status = 'error'; + } + if (uploadKeys.drive) { + this.finalizeUploadTask('drive', uploadKeys.drive, 'error'); + } + if (uploadKeys.dropbox) { + this.finalizeUploadTask('dropbox', uploadKeys.dropbox, 'error'); + } + } finally { + this.updateCloudFooter(); + } + } + + cleanupDownloadUrls() { + if (!this.activeDownloadUrls || !this.activeDownloadUrls.length) { + return; + } + this.activeDownloadUrls.forEach((url) => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke object URL', error); + } + }); + this.activeDownloadUrls = []; + } + + dispose() { + if (this.rosterTimer) { + clearInterval(this.rosterTimer); + this.rosterTimer = null; + } + if (this.diskStateListener) { + window.removeEventListener(PODCAST_DISK_EVENT, this.diskStateListener); + this.diskStateListener = null; + } + if (this.cloudStateListener) { + window.removeEventListener(PODCAST_CLOUD_EVENT, this.cloudStateListener); + this.cloudStateListener = null; + } + if (this.boundDriveProgressHandler) { + window.removeEventListener(DRIVE_PROGRESS_EVENT, this.boundDriveProgressHandler); + this.boundDriveProgressHandler = null; + } + if (this.levelOff) { + this.levelOff(); + this.levelOff = null; + } + if (this.abortUploadsController) { + this.abortUploadsController.abort(); + this.abortUploadsController = null; + } + if (this.hostMic?.active || this.virtualParticipants.size) { + this.disableHostMic().catch((error) => { + console.warn('Failed to disable host microphone during dispose', error); + }); + } + this.restoreRemoteControls(); + if (this.chatModule) { + try { + if (this.chatModule.dataset) { + delete this.chatModule.dataset.podcastOverlay; + } + if (this.chatPlaceholder?.parentNode) { + this.chatPlaceholder.parentNode.insertBefore(this.chatModule, this.chatPlaceholder); + this.chatPlaceholder.parentNode.removeChild(this.chatPlaceholder); + } + const legacyHeader = this.chatModule.querySelector('.chat-header'); + if (legacyHeader) { + if (legacyHeader.dataset && Object.prototype.hasOwnProperty.call(legacyHeader.dataset, 'podcastDisplay')) { + legacyHeader.style.display = legacyHeader.dataset.podcastDisplay || ''; + delete legacyHeader.dataset.podcastDisplay; + } else { + legacyHeader.style.display = ''; + } + } + const legacyResizer = this.chatModule.querySelector('.resizer'); + if (legacyResizer) { + if (legacyResizer.dataset && Object.prototype.hasOwnProperty.call(legacyResizer.dataset, 'podcastDisplay')) { + legacyResizer.style.display = legacyResizer.dataset.podcastDisplay || ''; + delete legacyResizer.dataset.podcastDisplay; + } else { + legacyResizer.style.display = ''; + } + } + const popLink = this.chatModule.querySelector('#popOutChat'); + if (popLink) { + popLink.style.display = ''; + } + const closeLink = this.chatModule.querySelector('#closeChat'); + if (closeLink) { + closeLink.style.display = ''; + } + if (this.chatModule.style) { + this.chatModule.style.position = ''; + this.chatModule.style.right = ''; + this.chatModule.style.left = ''; + this.chatModule.style.bottom = ''; + this.chatModule.style.top = ''; + this.chatModule.style.zIndex = ''; + this.chatModule.style.maxWidth = ''; + this.chatModule.style.width = ''; + this.chatModule.style.height = ''; + this.chatModule.style.maxHeight = ''; + this.chatModule.style.overflow = ''; + this.chatModule.style.margin = ''; + } + this.chatModule.classList.add('hidden'); + } catch (error) { + console.warn('Failed to restore chat module', error); + } + this.chatModule = null; + this.chatPlaceholder = null; + } + this.chatPanel = null; + this.chatCollapseButton = null; + this.chatPopoutButton = null; + this.chatPopoutAnchor = null; + this.chatCollapsed = false; + this.chatCollapsedHint = null; + this.cleanupDownloadUrls(); + if (this.stopMeterBridge) { + this.stopMeterBridge(); + this.stopMeterBridge = null; + } + if (this.inviteCopyTimer) { + clearTimeout(this.inviteCopyTimer); + this.inviteCopyTimer = null; + } + if (this.remoteOverlay && this.remoteOverlay.parentNode) { + this.remoteOverlay.parentNode.removeChild(this.remoteOverlay); + } + this.remoteOverlay = null; + this.remoteOverlayContent = null; + this.rosterDriveButtons.clear(); + this.rosterDriveStatuses.clear(); + this.driveStatusResetTimers.forEach((timer) => clearTimeout(timer)); + this.driveStatusResetTimers.clear(); + } +} + +async function bootstrap() { + try { + const preflight = await ensureRoomSelection(); + if (preflight?.redirect) { + return; + } + const app = new PodcastStudioApp({ roomHint: preflight?.roomSlug }); + await app.init(); + window.podcastStudioApp = app; + } catch (error) { + console.error('Failed to initialise podcast studio', error); + } +} + +bootstrap(); diff --git a/popout.html b/popout.html index fc0c959..510a27a 100644 --- a/popout.html +++ b/popout.html @@ -51,7 +51,7 @@ overflow-y: auto; } - .outMessage, .inMessage { + .outMessage, .inMessage, .tipMessage { display: flex; align-items: center; justify-content: flex-end; @@ -70,6 +70,15 @@ background-color: var(--discord-grey-7); } + .tipMessage { + background: linear-gradient(135deg, #ffd700 0%, #ffb700 100%); + color: #1a1a1a; + text-align: center; + justify-content: center; + font-weight: 500; + border: 1px solid #e6a800; + } + .outMessage .chat_message { font-weight: bold; } @@ -409,58 +418,58 @@ function sendChatMessage() { updateMessages({"msg": msg, type: "sent", time: new Date()}); } -function sanitize(string) { - var temp = document.createElement('div'); - temp.textContent = string; - return temp.innerHTML; -} - -function decodeHTML(value) { - if (value === null || value === undefined) { - return ""; - } - var temp = document.createElement("textarea"); - temp.innerHTML = value; - return temp.value; -} - -function replaceURLs(message) { - if (message === undefined || message === null) { - return ""; - } - var original = decodeHTML(String(message)); - var urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; - var result = ""; - var lastIndex = 0; - var match; - while ((match = urlRegex.exec(original)) !== null) { - result += sanitize(original.slice(lastIndex, match.index)); - var url = match[0]; - var trailing = ""; - while (/[.,;!:\*\?)]$/.test(url)) { - trailing = url.slice(-1) + trailing; - url = url.slice(0, -1); - } - if (url) { - var hyperlink = url; - if (!/^https?:\/\//i.test(hyperlink)) { - hyperlink = "http://" + hyperlink; - } - var display = url.length > 35 ? url.substring(0, 35) + "..." : url; - result += '' + sanitize(display) + ""; - } else { - result += sanitize(match[0]); - } - if (trailing) { - result += sanitize(trailing); - } - lastIndex = match.index + match[0].length; - } - if (lastIndex < original.length) { - result += sanitize(original.slice(lastIndex)); - } - return result; -} +function sanitize(string) { + var temp = document.createElement('div'); + temp.textContent = string; + return temp.innerHTML; +} + +function decodeHTML(value) { + if (value === null || value === undefined) { + return ""; + } + var temp = document.createElement("textarea"); + temp.innerHTML = value; + return temp.value; +} + +function replaceURLs(message) { + if (message === undefined || message === null) { + return ""; + } + var original = decodeHTML(String(message)); + var urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; + var result = ""; + var lastIndex = 0; + var match; + while ((match = urlRegex.exec(original)) !== null) { + result += sanitize(original.slice(lastIndex, match.index)); + var url = match[0]; + var trailing = ""; + while (/[.,;!:\*\?)]$/.test(url)) { + trailing = url.slice(-1) + trailing; + url = url.slice(0, -1); + } + if (url) { + var hyperlink = url; + if (!/^https?:\/\//i.test(hyperlink)) { + hyperlink = "http://" + hyperlink; + } + var display = url.length > 35 ? url.substring(0, 35) + "..." : url; + result += '' + sanitize(display) + ""; + } else { + result += sanitize(match[0]); + } + if (trailing) { + result += sanitize(trailing); + } + lastIndex = match.index + match[0].length; + } + if (lastIndex < original.length) { + result += sanitize(original.slice(lastIndex)); + } + return result; +} function EnterButtonChat(event){ // Number 13 is the "Enter" key on the keyboard @@ -502,68 +511,74 @@ function timeSince(date) { } -function updateMessages(message = false){ - if (message){ - var time = timeSince(message.time); - var msg = document.createElement("div"); - ////// KEEP THIS IN ///////// - //console.log(message.msg); // Display Recieved messages for View-Only clients. - ///////////////////////////// - var label = message.label ? decodeHTML(message.label) : ""; - var labelText = sanitize(label); - var safeMessage = replaceURLs(message.msg); - var safeTime = sanitize(time); - - if (message.type == "sent"){ - msg.innerHTML = ""+safeMessage + " - "+safeTime+""+labelText+""; - msg.classList.add("outMessage"); - } else if (message.type == "recv"){ - msg.innerHTML = label+""+safeMessage + " - "+safeTime+""; - msg.classList.add("inMessage"); - } else if (message.type == "action"){ - msg.innerHTML = label+""+safeMessage + " - "+safeTime+""; - msg.classList.add("actionMessage"); - } else if (message.type == "alert"){ - msg.innerHTML = ""+safeMessage + " - "+safeTime+""; - msg.classList.add("inMessage"); - } else { - msg.innerHTML = ""+safeMessage + " - "+safeTime+""; - msg.classList.add("inMessage"); - } - document.getElementById("chatBody").appendChild(msg); - } else { - document.getElementById("chatBody").innerHTML = ""; +function updateMessages(message = false){ + if (message){ + var time = timeSince(message.time); + var msg = document.createElement("div"); + ////// KEEP THIS IN ///////// + //console.log(message.msg); // Display Recieved messages for View-Only clients. + ///////////////////////////// + var label = message.label ? decodeHTML(message.label) : ""; + var labelText = sanitize(label); + var safeMessage = replaceURLs(message.msg); + var safeTime = sanitize(time); + + if (message.type == "sent"){ + msg.innerHTML = ""+safeMessage + " - "+safeTime+""+labelText+""; + msg.classList.add("outMessage"); + } else if (message.type == "recv"){ + msg.innerHTML = label+""+safeMessage + " - "+safeTime+""; + msg.classList.add("inMessage"); + } else if (message.type == "action"){ + msg.innerHTML = label+""+safeMessage + " - "+safeTime+""; + msg.classList.add("actionMessage"); + } else if (message.type == "alert"){ + msg.innerHTML = ""+safeMessage + " - "+safeTime+""; + msg.classList.add("inMessage"); + } else if (message.type == "tip"){ + msg.innerHTML = ""+safeMessage + " - "+safeTime+""; + msg.classList.add("tipMessage"); + } else { + msg.innerHTML = ""+safeMessage + " - "+safeTime+""; + msg.classList.add("inMessage"); + } + document.getElementById("chatBody").appendChild(msg); + } else { + document.getElementById("chatBody").innerHTML = ""; for (i in messageList){ - var time = timeSince(messageList[i].time); - var msg = document.createElement("div"); - ////// KEEP THIS IN ///////// - //console.log(messageList[i].msg); // Display Recieved messages for View-Only clients. - ///////////////////////////// - var label = messageList[i].label ? decodeHTML(messageList[i].label) : ""; - var labelText = sanitize(label); - - var message = replaceURLs(messageList[i].msg); - var safeTime = sanitize(time); - - if (messageList[i].type == "sent"){ - msg.innerHTML = ""+message + " - "+safeTime+""+labelText+""; - msg.classList.add("outMessage"); - } else if (messageList[i].type == "recv"){ - msg.innerHTML = label+""+message + " - "+safeTime+""; - msg.classList.add("inMessage"); - } else if (messageList[i].type == "action"){ - msg.innerHTML = label+""+message + " - "+safeTime+""; - msg.classList.add("actionMessage"); - } else if (messageList[i].type == "alert"){ - msg.innerHTML = ""+message + " - "+safeTime+""; - msg.classList.add("inMessage"); - } else { - msg.innerHTML = ""+message + " - "+safeTime+""; - msg.classList.add("inMessage"); - } - - document.getElementById("chatBody").appendChild(msg); - } + var time = timeSince(messageList[i].time); + var msg = document.createElement("div"); + ////// KEEP THIS IN ///////// + //console.log(messageList[i].msg); // Display Recieved messages for View-Only clients. + ///////////////////////////// + var label = messageList[i].label ? decodeHTML(messageList[i].label) : ""; + var labelText = sanitize(label); + + var message = replaceURLs(messageList[i].msg); + var safeTime = sanitize(time); + + if (messageList[i].type == "sent"){ + msg.innerHTML = ""+message + " - "+safeTime+""+labelText+""; + msg.classList.add("outMessage"); + } else if (messageList[i].type == "recv"){ + msg.innerHTML = label+""+message + " - "+safeTime+""; + msg.classList.add("inMessage"); + } else if (messageList[i].type == "action"){ + msg.innerHTML = label+""+message + " - "+safeTime+""; + msg.classList.add("actionMessage"); + } else if (messageList[i].type == "alert"){ + msg.innerHTML = ""+message + " - "+safeTime+""; + msg.classList.add("inMessage"); + } else if (messageList[i].type == "tip"){ + msg.innerHTML = ""+message + " - "+safeTime+""; + msg.classList.add("tipMessage"); + } else { + msg.innerHTML = ""+message + " - "+safeTime+""; + msg.classList.add("inMessage"); + } + + document.getElementById("chatBody").appendChild(msg); + } } //if (chatUpdateTimeout){ // clearInterval(chatUpdateTimeout); @@ -576,4 +591,4 @@ initializeCommunication(); - + diff --git a/recorder/monitor.html b/recorder/monitor.html index 5dba4f3..575734c 100644 --- a/recorder/monitor.html +++ b/recorder/monitor.html @@ -1,410 +1,410 @@ - - - - - - Self Monitor Audio | Listen to Your Input Devices - - - - - - - - - -
-

Self Monitor Audio

-

- Select a microphone and click Unmute Monitor when you're ready to listen. - Browsers require a user interaction before audio can play, so the monitor starts muted. -

-
- - - -
-
- - - -
- -
- - - - + + + + + + Self Monitor Audio | Listen to Your Input Devices + + + + + + + + + +
+

Self Monitor Audio

+

+ Select a microphone and click Unmute Monitor when you're ready to listen. + Browsers require a user interaction before audio can play, so the monitor starts muted. +

+
+ + + +
+
+ + + +
+ +
+ + + + diff --git a/thirdparty/CodecsHandler.js b/thirdparty/CodecsHandler.js index 444c570..090ef93 100644 --- a/thirdparty/CodecsHandler.js +++ b/thirdparty/CodecsHandler.js @@ -1,720 +1,720 @@ -/* -The MIT License (MIT) - -Copyright (c) 2012-2020 [Muaz Khan](https://github.com/muaz-khan) - - Permission is hereby granted, free of charge, to any person obtaining a copy of - this software and associated documentation files (the "Software"), to deal in - the Software without restriction, including without limitation the rights to - use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - the Software, and to permit persons to whom the Software is furnished to do so, - subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -// Sourced from: https://cdn.webrtc-experiment.com/CodecsHandler.js - -// *FILE HAS BEEN HEAVILY MODIFIED BY STEVE SEGUIN. ALL RIGHTS RESERVED WHERE APPLICABLE * - -var CodecsHandler = (function() { - function preferCodec(sdp, codec, useRed=false, useUlpfec=false) { - if (codec){ - codec = codec.toLowerCase(); - } - var info = splitLines(sdp); - if (!info.videoCodecNumbers) { - return sdp; - } - var preferCodecNumber = ''; - var preferErrorCorrectionNumbers = []; - if (codec === 'vp8') { - preferCodecNumber = info.vp8LineNumber || ''; - } else if (codec === 'vp9') { - preferCodecNumber = info.vp9LineNumber || ''; - } else if (codec === 'h264') { - preferCodecNumber = info.h264LineNumber || ''; - } else if (codec === 'h265') { - preferCodecNumber = info.h265LineNumber || ''; - } else if (codec === 'av1') { - preferCodecNumber = info.av1LineNumber || ''; - } else if (codec === 'red') { // you can treat red as a codec - preferCodecNumber = info.redLineNumber || ''; - } else if (codec === 'fec') { - preferCodecNumber = info.ulpfecLineNumber || ''; - } - if (useRed && info.redLineNumber) { // or as a setting - preferErrorCorrectionNumbers.push(info.redLineNumber); - } - if (useUlpfec && info.ulpfecLineNumber) { - preferErrorCorrectionNumbers.push(info.ulpfecLineNumber); - } - if (preferCodecNumber === '') { - return sdp; - } - var newOrder = [preferCodecNumber].concat(preferErrorCorrectionNumbers); - info.videoCodecNumbers.forEach(function(codecNumber) { - if (!newOrder.includes(codecNumber)) { - newOrder.push(codecNumber); - } - }); - var newLine = info.videoCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); - sdp = sdp.replace(info.videoCodecNumbersOriginal, newLine); - return sdp; - } - function splitLines(sdp) { - var info = {}; - sdp.split('\n').forEach(function(line) { - if (line.indexOf('m=video') === 0) { - info.videoCodecNumbers = []; - line.split('SAVPF')[1].split(' ').forEach(function(codecNumber) { - codecNumber = codecNumber.trim(); - if (!codecNumber || !codecNumber.length) return; - info.videoCodecNumbers.push(codecNumber); - info.videoCodecNumbersOriginal = line; - }); - } - var LINE = line.toUpperCase(); - if (LINE.includes('VP8/90000') && !info.vp8LineNumber) { - info.vp8LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('VP9/90000') && !info.vp9LineNumber) { - info.vp9LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('H264/90000') && !info.h264LineNumber) { - info.h264LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('H265/90000') && !info.h265LineNumber) { - info.h265LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('AV1X/90000') && !info.av1LineNumber) { - info.av1LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } else if (LINE.includes('AV1/90000') && !info.av1LineNumber) { - info.av1LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('RED/90000') && !info.redLineNumber) { - info.redLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('ULPFEC/90000') && !info.ulpfecLineNumber) { - info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - }); - return info; - } - - function preferAudioCodec(sdp, codec, useRed = false, useUlpfec = false) { - codec = codec ? codec.toLowerCase() : null; - var info = splitAudioLines(sdp); - if (!info.audioCodecNumbers) { - return sdp; - } - - var preferCodecNumber = ''; - var errorCorrectionNumbers = []; - - // Set preferred codec number - if (codec && info[codec + 'LineNumber']) { - preferCodecNumber = info[codec + 'LineNumber']; - } - - // Handle RED/ULPFEC error correction - if (useRed && info.redLineNumber) { - if (info.redPcmLineNumber) { - errorCorrectionNumbers.push(info.redPcmLineNumber); - } else if (info.redLineNumber) { - errorCorrectionNumbers.push(info.redLineNumber); - } - } - if (useUlpfec && info.ulpfecLineNumber) { - errorCorrectionNumbers.push(info.ulpfecLineNumber); - } - - // Set codec order: preferred codec + error correction + others - var newOrder = []; - if (preferCodecNumber) { - newOrder.push(preferCodecNumber); - } - errorCorrectionNumbers.forEach(function(codecNumber) { - if (!newOrder.includes(codecNumber)) { - newOrder.push(codecNumber); - } - }); - info.audioCodecNumbers.forEach(function(codecNumber) { - if (!newOrder.includes(codecNumber)) { - newOrder.push(codecNumber); - } - }); - - // Replace SDP line with updated codec order - var newLine = info.audioCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); - sdp = sdp.replace(info.audioCodecNumbersOriginal, newLine); - - return sdp; - } - - function splitAudioLines(sdp) { - var info = {}; - sdp.split('\n').forEach(function(line) { - if (line.indexOf('m=audio') === 0) { - info.audioCodecNumbers = []; - line.split('SAVPF')[1].split(' ').forEach(function(codecNumber) { - codecNumber = codecNumber.trim(); - if (!codecNumber || !codecNumber.length) return; - info.audioCodecNumbers.push(codecNumber); - info.audioCodecNumbersOriginal = line; - }); - } - var LINE = line.toLowerCase(); - if (LINE.includes('opus/48000') && !info.opusLineNumber) { - info.opusLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('isac/32000') && !info.isacLineNumber) { - info.isacLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('g722/8000') && !info.g722LineNumber) { - info.g722LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('pcmu/8000') && !info.pcmuLineNumber) { - info.pcmuLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('pcma/8000') && !info.pcmaLineNumber) { - info.pcmaLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('red/48000') && !info.redLineNumber) { - info.redLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('ulpfec/48000') && !info.ulpfecLineNumber) { - info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('red/8000') && !info.redPcmLineNumber) { - info.redPcmLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - if (LINE.includes('ulpfec/8000') && !info.ulpfecLineNumber) { - info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; - } - }); - return info; - } - - function extractSdp(sdpLine, pattern) { - var result = sdpLine.match(pattern); - return result && result.length === 2 ? result[1] : null; - } - - function addRedForPcmToSdp(sdp, info, redPcmLine) { - if (!info.audioCodecNumbers.includes(redPcmLine)) { - var newOrder = info.audioCodecNumbers.filter(codecNumber => codecNumber !== redPcmLine); - newOrder.unshift(redPcmLine); // Add RED for PCM at the start - var newLine = info.audioCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); - sdp = sdp.replace(info.audioCodecNumbersOriginal, newLine); - } - return sdp; - } - - - function disableNACK(sdp) { - if (!sdp || typeof sdp !== 'string') { - throw 'Invalid arguments.'; - } - - sdp = sdp.replace(/a=rtcp-fb:(\d+) nack\r\n/g, ''); - sdp = sdp.replace(/a=rtcp-fb:(\d+) nack pli\r\n/g, 'a=rtcp-fb:$1 pli\r\n'); - sdp = sdp.replace(/a=rtcp-fb:(\d+) pli nack\r\n/g, 'a=rtcp-fb:$1 pli\r\n'); - return sdp; - } - - function disableREMB(sdp) { - if (!sdp || typeof sdp !== 'string') { - throw 'Invalid arguments.'; - } - - sdp = sdp.replace(/a=rtcp-fb:(\d+) goog-remb\r\n/g, ''); - - return sdp; - } - - function disablePLI(sdp) { - if (!sdp || typeof sdp !== 'string') { - throw 'Invalid arguments.'; - } - - sdp = sdp.replace(/a=rtcp-fb:(\d+) pli\r\n/g, ''); - sdp = sdp.replace(/a=rtcp-fb:(\d+) nack pli\r\n/g, 'a=rtcp-fb:$1 nack\r\n'); - sdp = sdp.replace(/a=rtcp-fb:(\d+) pli nack\r\n/g, 'a=rtcp-fb:$1 nack\r\n'); - - return sdp; - } - - - // Find the line in sdpLines that starts with |prefix|, and, if specified, - // contains |substr| (case-insensitive search). - function findLine(sdpLines, prefix, substr) { - return findLineInRange(sdpLines, 0, -1, prefix, substr); - } - - // Find the line in sdpLines[startLine...endLine - 1] that starts with |prefix| - // and, if specified, contains |substr| (case-insensitive search). - function findLineInRange(sdpLines, startLine, endLine, prefix, substr) { - var realEndLine = endLine !== -1 ? endLine : sdpLines.length; - for (var i = startLine; i < realEndLine; ++i) { - if (sdpLines[i].indexOf(prefix) === 0) { - if (!substr || - sdpLines[i].toLowerCase().indexOf(substr.toLowerCase()) !== -1) { - return i; - } - } - } - return null; - } - - // Gets the codec payload type from an a=rtpmap:X line. - function getCodecPayloadType(sdpLine) { - var pattern = new RegExp('a=rtpmap:(\\d+) \\w+\\/\\d+'); - var result = sdpLine.match(pattern); - return (result && result.length === 2) ? result[1] : null; - } - - function getVideoBitrates(sdp) { - - var defaultBitrate = false; - - var sdpLines = sdp.split('\r\n'); - var mLineIndex = findLine(sdpLines, 'm=', 'video'); - if (mLineIndex === null) { - return defaultBitrate; - } - var videoMLine = sdpLines[mLineIndex]; - var pattern = new RegExp('m=video\\s\\d+\\s[A-Z/]+\\s'); - var sendPayloadType = videoMLine.split(pattern)[1].split(' ')[0]; - var fmtpLine = sdpLines[findLine(sdpLines, 'a=rtpmap', sendPayloadType)]; - var codec = fmtpLine.split('a=rtpmap:' + sendPayloadType)[1].split('/')[0]; - - var codecIndex = findLine(sdpLines, 'a=rtpmap', codec+'/90000'); - var codecPayload; - if (codecIndex) { - codecPayload = getCodecPayloadType(sdpLines[codecIndex]); - } - - if (!codecPayload) { - return defaultBitrate; - } - - var codecDetails = findLine(sdpLines, 'a=fmtp:'+codecPayload); - - var rtxIndex = findLine(sdpLines, 'a=rtpmap', 'rtx/90000'); - var rtxPayload; - if (rtxIndex) { - rtxPayload = getCodecPayloadType(sdpLines[rtxIndex]); - } - - if (!rtxIndex) { - return defaultBitrate; - } - - var rtxFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + codecPayload.toString()); - if (rtxFmtpLineIndex !== null) { - try { - var maxBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-max-bitrate=")[1].split(";")[0]); - var minBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-min-bitrate=")[1].split(";")[0]); - } catch(e){ - rtxFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + codecPayload.toString()); - if (rtxFmtpLineIndex !== null) { - try { - var maxBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-max-bitrate=")[1].split(";")[0]); - var minBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-min-bitrate=")[1].split(";")[0]); - } catch(e){ - return defaultBitrate; - } - } else { - return defaultBitrate; - } - } - - if (minBitrate>maxBitrate){ - maxBitrate = minBitrate; - } - if (maxBitrate<1){maxBitrate=1;} - return maxBitrate - } else { - return defaultBitrate; - } - } - - function setVideoBitrates(sdp, params = false, codec=false) { // modified + Improved by Steve. - - if (codec){ - codec = codec.toUpperCase(); - } else{ - codec="VP8"; - } - - var sdpLines = sdp.split('\r\n'); - - // Search for m line. - var mLineIndex = findLine(sdpLines, 'm=', 'video'); - if (mLineIndex === null) { - return sdp; - } - // Figure out the first codec payload type on the m=video SDP line. - var videoMLine = sdpLines[mLineIndex]; - var pattern = new RegExp('m=video\\s\\d+\\s[A-Z/]+\\s'); - var sendPayloadType = videoMLine.split(pattern)[1].split(' ')[0]; - var fmtpLine = sdpLines[findLine(sdpLines, 'a=rtpmap', sendPayloadType)]; - var codecName = fmtpLine.split('a=rtpmap:' + sendPayloadType)[1].split('/')[0]; - - codec = codecName || codec; // Try to find first Codec; else use expected/default - - params = params || {}; - - var min_bitrate = "30"; - if (params.min){ - min_bitrate = params.min.toString() || '30'; - } - var max_bitrate = "2500"; - if (params.max){ - max_bitrate = params.max.toString() || '2500'; - } - - var codecIndex = findLine(sdpLines, 'a=rtpmap', codec+'/90000'); - var codecPayload; - if (codecIndex) { - codecPayload = getCodecPayloadType(sdpLines[codecIndex]); - } - - if (!codecPayload) { - return sdp; - } - - var rtxIndex = findLine(sdpLines, 'a=rtpmap', 'rtx/90000'); - var rtxPayload; - if (rtxIndex) { - rtxPayload = getCodecPayloadType(sdpLines[rtxIndex]); - } - - if (!rtxIndex) { - sdpLines[mLineIndex] += '\r\nb=AS:' + max_bitrate; - sdp = sdpLines.join('\r\n'); - return sdp; - } - - - var rtxFmtpLineIndexChromium = findLine(sdpLines, 'a=fmtp:' + rtxPayload.toString()); - - if (rtxFmtpLineIndexChromium !== null){ - var appendrtxNext = '\r\n'; - appendrtxNext += 'a=fmtp:' + codecPayload + ' x-google-min-bitrate=' + min_bitrate + '; x-google-max-bitrate=' + max_bitrate; - sdpLines[rtxFmtpLineIndexChromium] = sdpLines[rtxFmtpLineIndexChromium].concat(appendrtxNext); - sdp = sdpLines.join('\r\n'); - } - - return sdp; - } - - function processOpus(sdpLines, opusPayload, opusIndex, codecType, params, debug){ - var opusFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + opusPayload.toString()); - if (opusFmtpLineIndex === null) { - return sdpLines; - } - - var appendOpusNext = ''; - - // Please see https://tools.ietf.org/html/rfc7587 for more details on OPUS settings - - if (typeof params.minptime != 'undefined') { // max packet size in milliseconds - if (params.minptime != false) { - appendOpusNext += ';minptime:' + params.minptime; // 3, 5, 10, 20, 40, 60 and the default is 120. (20 is minimum recommended for webrtc) - } - } - - if (typeof params.maxptime != 'undefined') { // max packet size in milliseconds - if (params.maxptime != false) { - appendOpusNext += ';maxptime:' + params.maxptime; // 3, 5, 10, 20, 40, 60 and the default is 120. (20 is minimum recommended for webrtc) - } - } - - if (typeof params.ptime != 'undefined') { // packet size; webrtc doesn't support less than 10 or 20 I think. - if (params.ptime != false) { - appendOpusNext += ';ptime:' + params.ptime; - } - } - - if (typeof params.stereo != 'undefined'){ - // Remove existing stereo settings - sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex] - .replace(/;stereo=[01]/g, '') - .replace(/;sprop-stereo=[01]/g, ''); - - if (params.stereo == 1){ - appendOpusNext += ';stereo=1;sprop-stereo=1'; - } else if (params.stereo == 0){ - appendOpusNext += ';stereo=0;sprop-stereo=0'; - } else if (params.stereo == 2 && codecType === 'OPUS'){ - sdpLines[opusIndex] = sdpLines[opusIndex].replace("opus/48000/2", "multiopus/48000/6"); - appendOpusNext += ';channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'; - } else if (params.stereo == 3 && codecType === 'OPUS'){ - sdpLines[opusIndex] = sdpLines[opusIndex].replace("opus/48000/2", "multiopus/48000/8"); - appendOpusNext += ';channel_mapping=0,6,1,2,3,4,5,7;num_streams=5;coupled_streams=4'; - } - } - - if (typeof params.maxaveragebitrate != 'undefined') { - if (sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=").length==1){ - appendOpusNext += ';maxaveragebitrate=' + params.maxaveragebitrate; // default 32000? (kbps) - } - } - - if (typeof params.maxplaybackrate != 'undefined') { - if (sdpLines[opusFmtpLineIndex].split("maxplaybackrate=").length==1){ - appendOpusNext += ';maxplaybackrate=' + params.maxplaybackrate; // Default should be 48000 (hz) , 8000 to 48000 are valid options - } - } - - if (typeof params.cbr != 'undefined') { - if (sdpLines[opusFmtpLineIndex].split("cbr=").length==1){ - appendOpusNext += ';cbr=' + params.cbr; // default is 0 (vbr) - } - } - - if (typeof params.dtx != 'undefined') { - if (params.dtx){ - if (sdpLines[opusFmtpLineIndex].split("usedtx=").length==1){ - appendOpusNext += ';usedtx=1'; - } - } - } - - if (typeof params.useinbandfec != 'undefined') { // useful for handling packet loss - if (sdpLines[opusFmtpLineIndex].split("useinbandfec=").length==1){ - appendOpusNext += ';useinbandfec=' + params.useinbandfec; // Defaults to 0 - } else { - sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex].replace("useinbandfec="+(params.useinbandfec ? 0 : 1), "useinbandfec="+params.useinbandfec); - } - } - - if (appendOpusNext) { - sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex] + appendOpusNext; - } - - if (debug){ - console.log("Adding to SDP (" + codecType + "): "+appendOpusNext+" --> Result: "+sdpLines[opusFmtpLineIndex]); - } - return sdpLines; - } - - function setOpusAttributes(sdp, params, debug=false) { - params = params || {}; - - var sdpLines = sdp.split('\r\n'); - - var opusIndex = findLine(sdpLines, 'a=rtpmap', 'opus/48000'); - var opusPayload; - if (opusIndex) { - opusPayload = getCodecPayloadType(sdpLines[opusIndex]); - } - - if (!opusPayload) { - return sdp; - } - - if (opusPayload){ - if (debug) console.log("Processing OPUS codec"); - sdpLines = processOpus(sdpLines, opusPayload, opusIndex, "OPUS", params, debug); - } - - return sdpLines.join('\r\n'); - } - - - function getOpusBitrate(sdp) { - - var sdpLines = sdp.split('\r\n'); - - var opusIndex = findLine(sdpLines, 'a=rtpmap', 'opus/48000'); - var opusPayload; - if (opusIndex) { - opusPayload = getCodecPayloadType(sdpLines[opusIndex]); - } - - if (!opusPayload) { - return 0; - } - - var opusFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + opusPayload.toString()); - if (opusFmtpLineIndex === null) { - return 0; - } - - var appendOpusNext = ''; - - if (sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=").length>1){ - var tmp = sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=")[1]; - tmp = tmp.split('\r')[0]; - tmp = tmp.split('\n')[0]; - tmp = tmp.split(';')[0]; - tmp = parseInt(tmp); - return tmp; - } - return 32768; - } - - function modifyDescLyra(modifiedSDP) { // WIP - if (!modifiedSDP.includes("m=audio")){ // don't bother modifying if no audio line found - return modifiedSDP; - } - ///// Snippet based on Apache 2.0 licenced code. Source: https://github.com/Flash-Meeting/lyra-webrtc ////////// - modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/16000/1\r\na=fmtp:109 ptime=20\r\na=rtpmap:111"); - modifiedSDP = modifiedSDP.replace("a=rtpmap:106 CN/32000\r\n", "").replace("a=rtpmap:105 CN/16000\r\n", "").replace("a=rtpmap:13 CN/8000\r\n", "").replace(" 106 105 13", ""); - /////////////////////////////// - return modifiedSDP; - } - - function modifyDescPCM(modifiedSDP, rate=32000, stereo=false, ptimeOverride=false) { - if (!modifiedSDP.includes("m=audio")){ // don't bother modifying if no audio line found - return modifiedSDP; - } - var ptime = 10; - if (ptimeOverride){ - ptime = parseInt(ptimeOverride); // 10 seems to work with 48000, so might as well make it default - } - ptime = parseInt(ptime/10)*10; - if (ptime<10){ - ptime = 10; - } - rate = parseInt(rate) || 32000; - - - if (!stereo && (rate>=48000)){ - rate = 48000; // 44100 doesn't want to work for me, so we'll skip it. - ptime = 10; // 48000 only works with ptime=10 - } else if (!stereo && rate>=44100){ - rate = 44100; // 44100 doesn't want to work for me, so we'll skip it. - ptime = 10; - } else if (rate>=32000){ - rate = 32000; - if (stereo){ - ptime=10; // can be ptime = 20 if not stereo - } else if (ptime>20){ - ptime=20; - } - } else if (rate>=16000){ - rate = 16000; - if (stereo){ - if (ptime>20){ - ptime=20; // can be ptime = 20 if not stereo - } - } else if (ptime>40){ - ptime=40; - } - } else { - rate = 8000; - if (stereo){ - if (ptime>40){ - ptime=40; // can be ptime = 20 if not stereo - } - } - } - - if (stereo){ - modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/"+rate+"/2\r\na=fmtp:109 ptime="+ptime+"\r\na=rtpmap:111"); - } else { - modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/"+rate+"/1\r\na=fmtp:109 ptime="+ptime+"\r\na=rtpmap:111"); - } - - modifiedSDP = modifiedSDP.replace("a=rtpmap:106 CN/32000\r\n", "").replace("a=rtpmap:105 CN/16000\r\n", "").replace("a=rtpmap:13 CN/8000\r\n", "").replace(" 106 105 13", ""); - return modifiedSDP; - } - - function modifySdp(sdp, disableAudio = false, disableVideo = false) { - if (!sdp || typeof sdp !== 'string') { - throw 'Invalid arguments.'; - } - let sdpLines = sdp.split('\r\n'); - let modifiedLines = []; - let inAudioSection = false; - let inVideoSection = false; - let bundleIds = []; - - for (let line of sdpLines) { - if (line.startsWith('m=audio')) { - inAudioSection = true; - inVideoSection = false; - if (!disableAudio) { - modifiedLines.push(line); - bundleIds.push('0'); - } - } else if (line.startsWith('m=video')) { - inAudioSection = false; - inVideoSection = true; - if (!disableVideo) { - modifiedLines.push(line); - bundleIds.push('1'); - } else { - modifiedLines.push(''); // Add a line break if video is disabled - } - } else if (inVideoSection && disableVideo) { - continue; // Skip video lines if video is disabled - } else if (line.startsWith('a=group:')) { - // Skip existing group lines, we'll add updated ones later - } else if (inAudioSection && disableAudio) { - // Skip audio lines if audio is disabled - } else { - modifiedLines.push(line); - } - } - const tLineIndex = modifiedLines.findIndex(line => line.startsWith('t=')); - if (bundleIds.length > 0) { - modifiedLines.splice(tLineIndex + 1, 0, - `a=group:BUNDLE ${bundleIds.join(' ')}`, - `a=group:LS ${bundleIds.join(' ')}` - ); - } - - // Ensure there's a line break at the end - if (modifiedLines[modifiedLines.length - 1] !== '') { - modifiedLines.push(''); - } - - return modifiedLines.join('\r\n'); - } - - return { - modifySdp: modifySdp, - - disableNACK: disableNACK, - - disablePLI: disablePLI, - - disableREMB: disableREMB, - - modifyDescPCM: modifyDescPCM, - - modifyDescLyra: modifyDescLyra, - - getVideoBitrates: function(sdp) { - return getVideoBitrates(sdp); - }, - - setVideoBitrates: function(sdp, params, codec) { - return setVideoBitrates(sdp, params, codec); - }, - setOpusAttributes: function(sdp, params, debug=false) { - return setOpusAttributes(sdp, params, debug); - }, - - getOpusBitrate: function(sdp){ - return getOpusBitrate(sdp); - }, - - preferCodec: preferCodec, - - preferAudioCodec: preferAudioCodec - }; -})(); +/* +The MIT License (MIT) + +Copyright (c) 2012-2020 [Muaz Khan](https://github.com/muaz-khan) + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Sourced from: https://cdn.webrtc-experiment.com/CodecsHandler.js + +// *FILE HAS BEEN HEAVILY MODIFIED BY STEVE SEGUIN. ALL RIGHTS RESERVED WHERE APPLICABLE * + +var CodecsHandler = (function() { + function preferCodec(sdp, codec, useRed=false, useUlpfec=false) { + if (codec){ + codec = codec.toLowerCase(); + } + var info = splitLines(sdp); + if (!info.videoCodecNumbers) { + return sdp; + } + var preferCodecNumber = ''; + var preferErrorCorrectionNumbers = []; + if (codec === 'vp8') { + preferCodecNumber = info.vp8LineNumber || ''; + } else if (codec === 'vp9') { + preferCodecNumber = info.vp9LineNumber || ''; + } else if (codec === 'h264') { + preferCodecNumber = info.h264LineNumber || ''; + } else if (codec === 'h265') { + preferCodecNumber = info.h265LineNumber || ''; + } else if (codec === 'av1') { + preferCodecNumber = info.av1LineNumber || ''; + } else if (codec === 'red') { // you can treat red as a codec + preferCodecNumber = info.redLineNumber || ''; + } else if (codec === 'fec') { + preferCodecNumber = info.ulpfecLineNumber || ''; + } + if (useRed && info.redLineNumber) { // or as a setting + preferErrorCorrectionNumbers.push(info.redLineNumber); + } + if (useUlpfec && info.ulpfecLineNumber) { + preferErrorCorrectionNumbers.push(info.ulpfecLineNumber); + } + if (preferCodecNumber === '') { + return sdp; + } + var newOrder = [preferCodecNumber].concat(preferErrorCorrectionNumbers); + info.videoCodecNumbers.forEach(function(codecNumber) { + if (!newOrder.includes(codecNumber)) { + newOrder.push(codecNumber); + } + }); + var newLine = info.videoCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); + sdp = sdp.replace(info.videoCodecNumbersOriginal, newLine); + return sdp; + } + function splitLines(sdp) { + var info = {}; + sdp.split('\n').forEach(function(line) { + if (line.indexOf('m=video') === 0) { + info.videoCodecNumbers = []; + line.split('SAVPF')[1].split(' ').forEach(function(codecNumber) { + codecNumber = codecNumber.trim(); + if (!codecNumber || !codecNumber.length) return; + info.videoCodecNumbers.push(codecNumber); + info.videoCodecNumbersOriginal = line; + }); + } + var LINE = line.toUpperCase(); + if (LINE.includes('VP8/90000') && !info.vp8LineNumber) { + info.vp8LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('VP9/90000') && !info.vp9LineNumber) { + info.vp9LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('H264/90000') && !info.h264LineNumber) { + info.h264LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('H265/90000') && !info.h265LineNumber) { + info.h265LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('AV1X/90000') && !info.av1LineNumber) { + info.av1LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } else if (LINE.includes('AV1/90000') && !info.av1LineNumber) { + info.av1LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('RED/90000') && !info.redLineNumber) { + info.redLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('ULPFEC/90000') && !info.ulpfecLineNumber) { + info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + }); + return info; + } + + function preferAudioCodec(sdp, codec, useRed = false, useUlpfec = false) { + codec = codec ? codec.toLowerCase() : null; + var info = splitAudioLines(sdp); + if (!info.audioCodecNumbers) { + return sdp; + } + + var preferCodecNumber = ''; + var errorCorrectionNumbers = []; + + // Set preferred codec number + if (codec && info[codec + 'LineNumber']) { + preferCodecNumber = info[codec + 'LineNumber']; + } + + // Handle RED/ULPFEC error correction + if (useRed && info.redLineNumber) { + if (info.redPcmLineNumber) { + errorCorrectionNumbers.push(info.redPcmLineNumber); + } else if (info.redLineNumber) { + errorCorrectionNumbers.push(info.redLineNumber); + } + } + if (useUlpfec && info.ulpfecLineNumber) { + errorCorrectionNumbers.push(info.ulpfecLineNumber); + } + + // Set codec order: preferred codec + error correction + others + var newOrder = []; + if (preferCodecNumber) { + newOrder.push(preferCodecNumber); + } + errorCorrectionNumbers.forEach(function(codecNumber) { + if (!newOrder.includes(codecNumber)) { + newOrder.push(codecNumber); + } + }); + info.audioCodecNumbers.forEach(function(codecNumber) { + if (!newOrder.includes(codecNumber)) { + newOrder.push(codecNumber); + } + }); + + // Replace SDP line with updated codec order + var newLine = info.audioCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); + sdp = sdp.replace(info.audioCodecNumbersOriginal, newLine); + + return sdp; + } + + function splitAudioLines(sdp) { + var info = {}; + sdp.split('\n').forEach(function(line) { + if (line.indexOf('m=audio') === 0) { + info.audioCodecNumbers = []; + line.split('SAVPF')[1].split(' ').forEach(function(codecNumber) { + codecNumber = codecNumber.trim(); + if (!codecNumber || !codecNumber.length) return; + info.audioCodecNumbers.push(codecNumber); + info.audioCodecNumbersOriginal = line; + }); + } + var LINE = line.toLowerCase(); + if (LINE.includes('opus/48000') && !info.opusLineNumber) { + info.opusLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('isac/32000') && !info.isacLineNumber) { + info.isacLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('g722/8000') && !info.g722LineNumber) { + info.g722LineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('pcmu/8000') && !info.pcmuLineNumber) { + info.pcmuLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('pcma/8000') && !info.pcmaLineNumber) { + info.pcmaLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('red/48000') && !info.redLineNumber) { + info.redLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('ulpfec/48000') && !info.ulpfecLineNumber) { + info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('red/8000') && !info.redPcmLineNumber) { + info.redPcmLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + if (LINE.includes('ulpfec/8000') && !info.ulpfecLineNumber) { + info.ulpfecLineNumber = line.replace('a=rtpmap:', '').split(' ')[0]; + } + }); + return info; + } + + function extractSdp(sdpLine, pattern) { + var result = sdpLine.match(pattern); + return result && result.length === 2 ? result[1] : null; + } + + function addRedForPcmToSdp(sdp, info, redPcmLine) { + if (!info.audioCodecNumbers.includes(redPcmLine)) { + var newOrder = info.audioCodecNumbers.filter(codecNumber => codecNumber !== redPcmLine); + newOrder.unshift(redPcmLine); // Add RED for PCM at the start + var newLine = info.audioCodecNumbersOriginal.split('SAVPF')[0] + 'SAVPF ' + newOrder.join(' '); + sdp = sdp.replace(info.audioCodecNumbersOriginal, newLine); + } + return sdp; + } + + + function disableNACK(sdp) { + if (!sdp || typeof sdp !== 'string') { + throw 'Invalid arguments.'; + } + + sdp = sdp.replace(/a=rtcp-fb:(\d+) nack\r\n/g, ''); + sdp = sdp.replace(/a=rtcp-fb:(\d+) nack pli\r\n/g, 'a=rtcp-fb:$1 pli\r\n'); + sdp = sdp.replace(/a=rtcp-fb:(\d+) pli nack\r\n/g, 'a=rtcp-fb:$1 pli\r\n'); + return sdp; + } + + function disableREMB(sdp) { + if (!sdp || typeof sdp !== 'string') { + throw 'Invalid arguments.'; + } + + sdp = sdp.replace(/a=rtcp-fb:(\d+) goog-remb\r\n/g, ''); + + return sdp; + } + + function disablePLI(sdp) { + if (!sdp || typeof sdp !== 'string') { + throw 'Invalid arguments.'; + } + + sdp = sdp.replace(/a=rtcp-fb:(\d+) pli\r\n/g, ''); + sdp = sdp.replace(/a=rtcp-fb:(\d+) nack pli\r\n/g, 'a=rtcp-fb:$1 nack\r\n'); + sdp = sdp.replace(/a=rtcp-fb:(\d+) pli nack\r\n/g, 'a=rtcp-fb:$1 nack\r\n'); + + return sdp; + } + + + // Find the line in sdpLines that starts with |prefix|, and, if specified, + // contains |substr| (case-insensitive search). + function findLine(sdpLines, prefix, substr) { + return findLineInRange(sdpLines, 0, -1, prefix, substr); + } + + // Find the line in sdpLines[startLine...endLine - 1] that starts with |prefix| + // and, if specified, contains |substr| (case-insensitive search). + function findLineInRange(sdpLines, startLine, endLine, prefix, substr) { + var realEndLine = endLine !== -1 ? endLine : sdpLines.length; + for (var i = startLine; i < realEndLine; ++i) { + if (sdpLines[i].indexOf(prefix) === 0) { + if (!substr || + sdpLines[i].toLowerCase().indexOf(substr.toLowerCase()) !== -1) { + return i; + } + } + } + return null; + } + + // Gets the codec payload type from an a=rtpmap:X line. + function getCodecPayloadType(sdpLine) { + var pattern = new RegExp('a=rtpmap:(\\d+) \\w+\\/\\d+'); + var result = sdpLine.match(pattern); + return (result && result.length === 2) ? result[1] : null; + } + + function getVideoBitrates(sdp) { + + var defaultBitrate = false; + + var sdpLines = sdp.split('\r\n'); + var mLineIndex = findLine(sdpLines, 'm=', 'video'); + if (mLineIndex === null) { + return defaultBitrate; + } + var videoMLine = sdpLines[mLineIndex]; + var pattern = new RegExp('m=video\\s\\d+\\s[A-Z/]+\\s'); + var sendPayloadType = videoMLine.split(pattern)[1].split(' ')[0]; + var fmtpLine = sdpLines[findLine(sdpLines, 'a=rtpmap', sendPayloadType)]; + var codec = fmtpLine.split('a=rtpmap:' + sendPayloadType)[1].split('/')[0]; + + var codecIndex = findLine(sdpLines, 'a=rtpmap', codec+'/90000'); + var codecPayload; + if (codecIndex) { + codecPayload = getCodecPayloadType(sdpLines[codecIndex]); + } + + if (!codecPayload) { + return defaultBitrate; + } + + var codecDetails = findLine(sdpLines, 'a=fmtp:'+codecPayload); + + var rtxIndex = findLine(sdpLines, 'a=rtpmap', 'rtx/90000'); + var rtxPayload; + if (rtxIndex) { + rtxPayload = getCodecPayloadType(sdpLines[rtxIndex]); + } + + if (!rtxIndex) { + return defaultBitrate; + } + + var rtxFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + codecPayload.toString()); + if (rtxFmtpLineIndex !== null) { + try { + var maxBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-max-bitrate=")[1].split(";")[0]); + var minBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-min-bitrate=")[1].split(";")[0]); + } catch(e){ + rtxFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + codecPayload.toString()); + if (rtxFmtpLineIndex !== null) { + try { + var maxBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-max-bitrate=")[1].split(";")[0]); + var minBitrate = parseInt(sdpLines[rtxFmtpLineIndex].split("x-google-min-bitrate=")[1].split(";")[0]); + } catch(e){ + return defaultBitrate; + } + } else { + return defaultBitrate; + } + } + + if (minBitrate>maxBitrate){ + maxBitrate = minBitrate; + } + if (maxBitrate<1){maxBitrate=1;} + return maxBitrate + } else { + return defaultBitrate; + } + } + + function setVideoBitrates(sdp, params = false, codec=false) { // modified + Improved by Steve. + + if (codec){ + codec = codec.toUpperCase(); + } else{ + codec="VP8"; + } + + var sdpLines = sdp.split('\r\n'); + + // Search for m line. + var mLineIndex = findLine(sdpLines, 'm=', 'video'); + if (mLineIndex === null) { + return sdp; + } + // Figure out the first codec payload type on the m=video SDP line. + var videoMLine = sdpLines[mLineIndex]; + var pattern = new RegExp('m=video\\s\\d+\\s[A-Z/]+\\s'); + var sendPayloadType = videoMLine.split(pattern)[1].split(' ')[0]; + var fmtpLine = sdpLines[findLine(sdpLines, 'a=rtpmap', sendPayloadType)]; + var codecName = fmtpLine.split('a=rtpmap:' + sendPayloadType)[1].split('/')[0]; + + codec = codecName || codec; // Try to find first Codec; else use expected/default + + params = params || {}; + + var min_bitrate = "30"; + if (params.min){ + min_bitrate = params.min.toString() || '30'; + } + var max_bitrate = "2500"; + if (params.max){ + max_bitrate = params.max.toString() || '2500'; + } + + var codecIndex = findLine(sdpLines, 'a=rtpmap', codec+'/90000'); + var codecPayload; + if (codecIndex) { + codecPayload = getCodecPayloadType(sdpLines[codecIndex]); + } + + if (!codecPayload) { + return sdp; + } + + var rtxIndex = findLine(sdpLines, 'a=rtpmap', 'rtx/90000'); + var rtxPayload; + if (rtxIndex) { + rtxPayload = getCodecPayloadType(sdpLines[rtxIndex]); + } + + if (!rtxIndex) { + sdpLines[mLineIndex] += '\r\nb=AS:' + max_bitrate; + sdp = sdpLines.join('\r\n'); + return sdp; + } + + + var rtxFmtpLineIndexChromium = findLine(sdpLines, 'a=fmtp:' + rtxPayload.toString()); + + if (rtxFmtpLineIndexChromium !== null){ + var appendrtxNext = '\r\n'; + appendrtxNext += 'a=fmtp:' + codecPayload + ' x-google-min-bitrate=' + min_bitrate + '; x-google-max-bitrate=' + max_bitrate; + sdpLines[rtxFmtpLineIndexChromium] = sdpLines[rtxFmtpLineIndexChromium].concat(appendrtxNext); + sdp = sdpLines.join('\r\n'); + } + + return sdp; + } + + function processOpus(sdpLines, opusPayload, opusIndex, codecType, params, debug){ + var opusFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + opusPayload.toString()); + if (opusFmtpLineIndex === null) { + return sdpLines; + } + + var appendOpusNext = ''; + + // Please see https://tools.ietf.org/html/rfc7587 for more details on OPUS settings + + if (typeof params.minptime != 'undefined') { // max packet size in milliseconds + if (params.minptime != false) { + appendOpusNext += ';minptime:' + params.minptime; // 3, 5, 10, 20, 40, 60 and the default is 120. (20 is minimum recommended for webrtc) + } + } + + if (typeof params.maxptime != 'undefined') { // max packet size in milliseconds + if (params.maxptime != false) { + appendOpusNext += ';maxptime:' + params.maxptime; // 3, 5, 10, 20, 40, 60 and the default is 120. (20 is minimum recommended for webrtc) + } + } + + if (typeof params.ptime != 'undefined') { // packet size; webrtc doesn't support less than 10 or 20 I think. + if (params.ptime != false) { + appendOpusNext += ';ptime:' + params.ptime; + } + } + + if (typeof params.stereo != 'undefined'){ + // Remove existing stereo settings + sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex] + .replace(/;stereo=[01]/g, '') + .replace(/;sprop-stereo=[01]/g, ''); + + if (params.stereo == 1){ + appendOpusNext += ';stereo=1;sprop-stereo=1'; + } else if (params.stereo == 0){ + appendOpusNext += ';stereo=0;sprop-stereo=0'; + } else if (params.stereo == 2 && codecType === 'OPUS'){ + sdpLines[opusIndex] = sdpLines[opusIndex].replace("opus/48000/2", "multiopus/48000/6"); + appendOpusNext += ';channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'; + } else if (params.stereo == 3 && codecType === 'OPUS'){ + sdpLines[opusIndex] = sdpLines[opusIndex].replace("opus/48000/2", "multiopus/48000/8"); + appendOpusNext += ';channel_mapping=0,6,1,2,3,4,5,7;num_streams=5;coupled_streams=4'; + } + } + + if (typeof params.maxaveragebitrate != 'undefined') { + if (sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=").length==1){ + appendOpusNext += ';maxaveragebitrate=' + params.maxaveragebitrate; // default 32000? (kbps) + } + } + + if (typeof params.maxplaybackrate != 'undefined') { + if (sdpLines[opusFmtpLineIndex].split("maxplaybackrate=").length==1){ + appendOpusNext += ';maxplaybackrate=' + params.maxplaybackrate; // Default should be 48000 (hz) , 8000 to 48000 are valid options + } + } + + if (typeof params.cbr != 'undefined') { + if (sdpLines[opusFmtpLineIndex].split("cbr=").length==1){ + appendOpusNext += ';cbr=' + params.cbr; // default is 0 (vbr) + } + } + + if (typeof params.dtx != 'undefined') { + if (params.dtx){ + if (sdpLines[opusFmtpLineIndex].split("usedtx=").length==1){ + appendOpusNext += ';usedtx=1'; + } + } + } + + if (typeof params.useinbandfec != 'undefined') { // useful for handling packet loss + if (sdpLines[opusFmtpLineIndex].split("useinbandfec=").length==1){ + appendOpusNext += ';useinbandfec=' + params.useinbandfec; // Defaults to 0 + } else { + sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex].replace("useinbandfec="+(params.useinbandfec ? 0 : 1), "useinbandfec="+params.useinbandfec); + } + } + + if (appendOpusNext) { + sdpLines[opusFmtpLineIndex] = sdpLines[opusFmtpLineIndex] + appendOpusNext; + } + + if (debug){ + console.log("Adding to SDP (" + codecType + "): "+appendOpusNext+" --> Result: "+sdpLines[opusFmtpLineIndex]); + } + return sdpLines; + } + + function setOpusAttributes(sdp, params, debug=false) { + params = params || {}; + + var sdpLines = sdp.split('\r\n'); + + var opusIndex = findLine(sdpLines, 'a=rtpmap', 'opus/48000'); + var opusPayload; + if (opusIndex) { + opusPayload = getCodecPayloadType(sdpLines[opusIndex]); + } + + if (!opusPayload) { + return sdp; + } + + if (opusPayload){ + if (debug) console.log("Processing OPUS codec"); + sdpLines = processOpus(sdpLines, opusPayload, opusIndex, "OPUS", params, debug); + } + + return sdpLines.join('\r\n'); + } + + + function getOpusBitrate(sdp) { + + var sdpLines = sdp.split('\r\n'); + + var opusIndex = findLine(sdpLines, 'a=rtpmap', 'opus/48000'); + var opusPayload; + if (opusIndex) { + opusPayload = getCodecPayloadType(sdpLines[opusIndex]); + } + + if (!opusPayload) { + return 0; + } + + var opusFmtpLineIndex = findLine(sdpLines, 'a=fmtp:' + opusPayload.toString()); + if (opusFmtpLineIndex === null) { + return 0; + } + + var appendOpusNext = ''; + + if (sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=").length>1){ + var tmp = sdpLines[opusFmtpLineIndex].split("maxaveragebitrate=")[1]; + tmp = tmp.split('\r')[0]; + tmp = tmp.split('\n')[0]; + tmp = tmp.split(';')[0]; + tmp = parseInt(tmp); + return tmp; + } + return 32768; + } + + function modifyDescLyra(modifiedSDP) { // WIP + if (!modifiedSDP.includes("m=audio")){ // don't bother modifying if no audio line found + return modifiedSDP; + } + ///// Snippet based on Apache 2.0 licenced code. Source: https://github.com/Flash-Meeting/lyra-webrtc ////////// + modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/16000/1\r\na=fmtp:109 ptime=20\r\na=rtpmap:111"); + modifiedSDP = modifiedSDP.replace("a=rtpmap:106 CN/32000\r\n", "").replace("a=rtpmap:105 CN/16000\r\n", "").replace("a=rtpmap:13 CN/8000\r\n", "").replace(" 106 105 13", ""); + /////////////////////////////// + return modifiedSDP; + } + + function modifyDescPCM(modifiedSDP, rate=32000, stereo=false, ptimeOverride=false) { + if (!modifiedSDP.includes("m=audio")){ // don't bother modifying if no audio line found + return modifiedSDP; + } + var ptime = 10; + if (ptimeOverride){ + ptime = parseInt(ptimeOverride); // 10 seems to work with 48000, so might as well make it default + } + ptime = parseInt(ptime/10)*10; + if (ptime<10){ + ptime = 10; + } + rate = parseInt(rate) || 32000; + + + if (!stereo && (rate>=48000)){ + rate = 48000; // 44100 doesn't want to work for me, so we'll skip it. + ptime = 10; // 48000 only works with ptime=10 + } else if (!stereo && rate>=44100){ + rate = 44100; // 44100 doesn't want to work for me, so we'll skip it. + ptime = 10; + } else if (rate>=32000){ + rate = 32000; + if (stereo){ + ptime=10; // can be ptime = 20 if not stereo + } else if (ptime>20){ + ptime=20; + } + } else if (rate>=16000){ + rate = 16000; + if (stereo){ + if (ptime>20){ + ptime=20; // can be ptime = 20 if not stereo + } + } else if (ptime>40){ + ptime=40; + } + } else { + rate = 8000; + if (stereo){ + if (ptime>40){ + ptime=40; // can be ptime = 20 if not stereo + } + } + } + + if (stereo){ + modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/"+rate+"/2\r\na=fmtp:109 ptime="+ptime+"\r\na=rtpmap:111"); + } else { + modifiedSDP = modifiedSDP.replace("SAVPF 111", "SAVPF 109 111").replace("a=rtpmap:111", "a=rtpmap:109 L16/"+rate+"/1\r\na=fmtp:109 ptime="+ptime+"\r\na=rtpmap:111"); + } + + modifiedSDP = modifiedSDP.replace("a=rtpmap:106 CN/32000\r\n", "").replace("a=rtpmap:105 CN/16000\r\n", "").replace("a=rtpmap:13 CN/8000\r\n", "").replace(" 106 105 13", ""); + return modifiedSDP; + } + + function modifySdp(sdp, disableAudio = false, disableVideo = false) { + if (!sdp || typeof sdp !== 'string') { + throw 'Invalid arguments.'; + } + let sdpLines = sdp.split('\r\n'); + let modifiedLines = []; + let inAudioSection = false; + let inVideoSection = false; + let bundleIds = []; + + for (let line of sdpLines) { + if (line.startsWith('m=audio')) { + inAudioSection = true; + inVideoSection = false; + if (!disableAudio) { + modifiedLines.push(line); + bundleIds.push('0'); + } + } else if (line.startsWith('m=video')) { + inAudioSection = false; + inVideoSection = true; + if (!disableVideo) { + modifiedLines.push(line); + bundleIds.push('1'); + } else { + modifiedLines.push(''); // Add a line break if video is disabled + } + } else if (inVideoSection && disableVideo) { + continue; // Skip video lines if video is disabled + } else if (line.startsWith('a=group:')) { + // Skip existing group lines, we'll add updated ones later + } else if (inAudioSection && disableAudio) { + // Skip audio lines if audio is disabled + } else { + modifiedLines.push(line); + } + } + const tLineIndex = modifiedLines.findIndex(line => line.startsWith('t=')); + if (bundleIds.length > 0) { + modifiedLines.splice(tLineIndex + 1, 0, + `a=group:BUNDLE ${bundleIds.join(' ')}`, + `a=group:LS ${bundleIds.join(' ')}` + ); + } + + // Ensure there's a line break at the end + if (modifiedLines[modifiedLines.length - 1] !== '') { + modifiedLines.push(''); + } + + return modifiedLines.join('\r\n'); + } + + return { + modifySdp: modifySdp, + + disableNACK: disableNACK, + + disablePLI: disablePLI, + + disableREMB: disableREMB, + + modifyDescPCM: modifyDescPCM, + + modifyDescLyra: modifyDescLyra, + + getVideoBitrates: function(sdp) { + return getVideoBitrates(sdp); + }, + + setVideoBitrates: function(sdp, params, codec) { + return setVideoBitrates(sdp, params, codec); + }, + setOpusAttributes: function(sdp, params, debug=false) { + return setOpusAttributes(sdp, params, debug); + }, + + getOpusBitrate: function(sdp){ + return getOpusBitrate(sdp); + }, + + preferCodec: preferCodec, + + preferAudioCodec: preferAudioCodec + }; +})(); diff --git a/thirdparty/StreamSaver.js b/thirdparty/StreamSaver.js index fc83e34..ffede44 100644 --- a/thirdparty/StreamSaver.js +++ b/thirdparty/StreamSaver.js @@ -1,327 +1,327 @@ -/*! streamsaver. MIT License. Jimmy Wärting */ - -/* global chrome location ReadableStream define MessageChannel TransformStream */ - -function streamSaverFunction(){ - 'use strict' - - const global = typeof window === 'object' ? window : this; - if (!global.HTMLElement) console.warn('streamsaver is meant to run on browsers main thread') - - let mitmTransporter = null; - let supportsTransferable = false; - const test = fn => { try { fn() } catch (e) {} }; - const ponyfill = global.WebStreamsPolyfill || {}; - const isSecureContext = global.isSecureContext; - - //console.log(ponyfill); - //console.log(isSecureContext); - - // Enable blob fallback for iOS/Safari to fix file download issues - // Note: This is re-enabled specifically for iOS despite memory concerns - // We'll use split recording to mitigate memory issues - let useBlobFallback = /constructor/i.test(global.HTMLElement) || !!global.safari || !!global.WebKitPoint; - - // iOS detection for additional handling - const iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); - if (iOS) { - useBlobFallback = true; // Force blob fallback for iOS - console.log("iOS detected: Enabling blob fallback for recording downloads"); - } - - //console.log(useBlobFallback); - - const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style - ? 'iframe' - : 'navigate'; - - //console.log(downloadStrategy); - - function createWriteStream (filename, stopStream){ - //console.log("createWriteStream"); - let opts = { - size: null, - pathname: null, - writableStrategy: undefined, - readableStrategy: undefined - } - - let bytesWritten = 0 // by StreamSaver.js (not the service worker) - let downloadUrl = null - let channel = null - let ts = null - - if (!useBlobFallback) { - loadTransporter() - - channel = new MessageChannel() - - // Make filename RFC5987 compatible - filename = encodeURIComponent(filename.replace(/\//g, ':')) - .replace(/['()]/g, escape) - .replace(/\*/g, '%2A') - - const response = { - transferringReadable: supportsTransferable, - pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, - headers: { - 'Content-Type': 'application/octet-stream; charset=utf-8', - 'Content-Disposition': "attachment; filename*=UTF-8''" + filename - } - } - - if (opts.size) { - response.headers['Content-Length'] = opts.size - } - - const args = [ response, '*', [ channel.port2 ] ] - - if (supportsTransferable) { - const transformer = downloadStrategy === 'iframe' ? undefined : { - // This transformer & flush method is only used by insecure context. - transform (chunk, controller) { - if (!(chunk instanceof Uint8Array)) { - throw new TypeError('Can only write Uint8Arrays') - } - bytesWritten += chunk.length - controller.enqueue(chunk) - - if (downloadUrl) { - location.href = downloadUrl - downloadUrl = null - } - }, - flush () { - if (downloadUrl) { - location.href = downloadUrl - } - } - } - ts = new streamSaver.TransformStream( - transformer, - opts.writableStrategy, - opts.readableStrategy - ) - const readableStream = ts.readable - - channel.port1.postMessage({ readableStream }, [ readableStream ]) - } - - channel.port1.onmessage = evt => { - //console.log(evt); - // Service worker sent us a link that we should open. - if (evt.data.download) { - // Special treatment for popup... - if (downloadStrategy === 'navigate') { - mitmTransporter.remove() - mitmTransporter = null - if (bytesWritten) { - location.href = evt.data.download - } else { - downloadUrl = evt.data.download - } - } else { - if (mitmTransporter.isPopup) { - mitmTransporter.remove() - mitmTransporter = null - // Special case for firefox, they can keep sw alive with fetch - if (downloadStrategy === 'iframe') { - makeIframe(streamSaver.mitm) - } - } - - // We never remove this iframes b/c it can interrupt saving - makeIframe(evt.data.download) - } - } else if (evt.data.abort) { - stopStream(false, true); - chunks = [] - channel.port1.postMessage('abort') //send back so controller is aborted - channel.port1.onmessage = null - - setTimeout(function(channel){ - channel.port1.close() - channel.port2.close() - channel = null - },1300,channel); - } - } - - if (mitmTransporter.loaded) { - mitmTransporter.postMessage(...args) - } else { - mitmTransporter.addEventListener('load', () => { - mitmTransporter.postMessage(...args) - }, { once: true }) - } - } - - let chunks = [] - - return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ - write (chunk) { - if (!(chunk instanceof Uint8Array)) { - throw new TypeError('Can only write Uint8Arrays') - } - if (useBlobFallback) { - // Safari... The new IE6 - // https://github.com/jimmywarting/StreamSaver.js/issues/69 - // - // even though it has everything it fails to download anything - // that comes from the service worker..! - chunks.push(chunk) - return - } - - // is called when a new chunk of data is ready to be written - // to the underlying sink. It can return a promise to signal - // success or failure of the write operation. The stream - // implementation guarantees that this method will be called - // only after previous writes have succeeded, and never after - // close or abort is called. - - // TODO: Kind of important that service worker respond back when - // it has been written. Otherwise we can't handle backpressure - // EDIT: Transferable streams solves this... - try { - channel.port1.postMessage(chunk) - } catch(e){ - - }; - bytesWritten += chunk.length - if (downloadUrl) { - location.href = downloadUrl - downloadUrl = null - } - }, - close () { - if (useBlobFallback) { - const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - link.click() - } else { - channel.port1.postMessage('end') - } - }, - abort () { - chunks = [] - channel.port1.postMessage('abort') - channel.port1.onmessage = null - setTimeout(function(channel){ - channel.port1.close() - channel.port2.close() - channel = null - },1300,channel); - } - }, opts.writableStrategy) - } - - const streamSaver = { - createWriteStream, - WritableStream: global.WritableStream || ponyfill.WritableStream, - supported: true, - version: { full: '2.0.7', major: 2, minor: 0, dot: 7 }, - mitm: './thirdparty/mitm.html?v=4' - } - - //console.log(streamSaver); - - /** - * create a hidden iframe and append it to the DOM (body) - * - * @param {string} src page to load - * @return {HTMLIFrameElement} page to load - */ - function makeIframe (src) { - if (!src) throw new Error('meh') - const iframe = document.createElement('iframe') - iframe.hidden = true - iframe.src = src - iframe.loaded = false - iframe.name = 'iframe' - iframe.isIframe = true - iframe.credentialless = true - iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) - iframe.addEventListener('load', () => { - iframe.loaded = true - }, { once: true }) - document.body.appendChild(iframe) - return iframe - } - - /** - * create a popup that simulates the basic things - * of what a iframe can do - * - * @param {string} src page to load - * @return {object} iframe like object - */ - function makePopup (src) { - const options = 'width=200,height=100' - const delegate = document.createDocumentFragment() - const popup = { - frame: global.open(src, 'popup', options), - loaded: false, - isIframe: false, - isPopup: true, - remove () { popup.frame.close() }, - addEventListener (...args) { delegate.addEventListener(...args) }, - dispatchEvent (...args) { delegate.dispatchEvent(...args) }, - removeEventListener (...args) { delegate.removeEventListener(...args) }, - postMessage (...args) { popup.frame.postMessage(...args) } - } - - const onReady = evt => { - if (evt.source === popup.frame) { - popup.loaded = true - global.removeEventListener('message', onReady) - popup.dispatchEvent(new Event('load')) - } - } - - global.addEventListener('message', onReady) - - return popup - } - - try { - // We can't look for service worker since it may still work on http - new Response(new ReadableStream()) - if (isSecureContext && !('serviceWorker' in navigator)) { - useBlobFallback = true - } - } catch (err) { - useBlobFallback = true - } - - //console.log("useBlobFallback: "+useBlobFallback); - - test(() => { - // Transferable stream was first enabled in chrome v73 behind a flag - const { readable } = new TransformStream() - const mc = new MessageChannel() - mc.port1.postMessage(readable, [readable]) - mc.port1.close() - mc.port2.close() - supportsTransferable = true - // Freeze TransformStream object (can only work with native) - Object.defineProperty(streamSaver, 'TransformStream', { - configurable: false, - writable: false, - value: TransformStream - }) - }) - - function loadTransporter () { - if (!mitmTransporter) { - mitmTransporter = isSecureContext - ? makeIframe(streamSaver.mitm) - : makePopup(streamSaver.mitm) - } - } - - return streamSaver -}; +/*! streamsaver. MIT License. Jimmy Wärting */ + +/* global chrome location ReadableStream define MessageChannel TransformStream */ + +function streamSaverFunction(){ + 'use strict' + + const global = typeof window === 'object' ? window : this; + if (!global.HTMLElement) console.warn('streamsaver is meant to run on browsers main thread') + + let mitmTransporter = null; + let supportsTransferable = false; + const test = fn => { try { fn() } catch (e) {} }; + const ponyfill = global.WebStreamsPolyfill || {}; + const isSecureContext = global.isSecureContext; + + //console.log(ponyfill); + //console.log(isSecureContext); + + // Enable blob fallback for iOS/Safari to fix file download issues + // Note: This is re-enabled specifically for iOS despite memory concerns + // We'll use split recording to mitigate memory issues + let useBlobFallback = /constructor/i.test(global.HTMLElement) || !!global.safari || !!global.WebKitPoint; + + // iOS detection for additional handling + const iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); + if (iOS) { + useBlobFallback = true; // Force blob fallback for iOS + console.log("iOS detected: Enabling blob fallback for recording downloads"); + } + + //console.log(useBlobFallback); + + const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style + ? 'iframe' + : 'navigate'; + + //console.log(downloadStrategy); + + function createWriteStream (filename, stopStream){ + //console.log("createWriteStream"); + let opts = { + size: null, + pathname: null, + writableStrategy: undefined, + readableStrategy: undefined + } + + let bytesWritten = 0 // by StreamSaver.js (not the service worker) + let downloadUrl = null + let channel = null + let ts = null + + if (!useBlobFallback) { + loadTransporter() + + channel = new MessageChannel() + + // Make filename RFC5987 compatible + filename = encodeURIComponent(filename.replace(/\//g, ':')) + .replace(/['()]/g, escape) + .replace(/\*/g, '%2A') + + const response = { + transferringReadable: supportsTransferable, + pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, + headers: { + 'Content-Type': 'application/octet-stream; charset=utf-8', + 'Content-Disposition': "attachment; filename*=UTF-8''" + filename + } + } + + if (opts.size) { + response.headers['Content-Length'] = opts.size + } + + const args = [ response, '*', [ channel.port2 ] ] + + if (supportsTransferable) { + const transformer = downloadStrategy === 'iframe' ? undefined : { + // This transformer & flush method is only used by insecure context. + transform (chunk, controller) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('Can only write Uint8Arrays') + } + bytesWritten += chunk.length + controller.enqueue(chunk) + + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + flush () { + if (downloadUrl) { + location.href = downloadUrl + } + } + } + ts = new streamSaver.TransformStream( + transformer, + opts.writableStrategy, + opts.readableStrategy + ) + const readableStream = ts.readable + + channel.port1.postMessage({ readableStream }, [ readableStream ]) + } + + channel.port1.onmessage = evt => { + //console.log(evt); + // Service worker sent us a link that we should open. + if (evt.data.download) { + // Special treatment for popup... + if (downloadStrategy === 'navigate') { + mitmTransporter.remove() + mitmTransporter = null + if (bytesWritten) { + location.href = evt.data.download + } else { + downloadUrl = evt.data.download + } + } else { + if (mitmTransporter.isPopup) { + mitmTransporter.remove() + mitmTransporter = null + // Special case for firefox, they can keep sw alive with fetch + if (downloadStrategy === 'iframe') { + makeIframe(streamSaver.mitm) + } + } + + // We never remove this iframes b/c it can interrupt saving + makeIframe(evt.data.download) + } + } else if (evt.data.abort) { + stopStream(false, true); + chunks = [] + channel.port1.postMessage('abort') //send back so controller is aborted + channel.port1.onmessage = null + + setTimeout(function(channel){ + channel.port1.close() + channel.port2.close() + channel = null + },1300,channel); + } + } + + if (mitmTransporter.loaded) { + mitmTransporter.postMessage(...args) + } else { + mitmTransporter.addEventListener('load', () => { + mitmTransporter.postMessage(...args) + }, { once: true }) + } + } + + let chunks = [] + + return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ + write (chunk) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('Can only write Uint8Arrays') + } + if (useBlobFallback) { + // Safari... The new IE6 + // https://github.com/jimmywarting/StreamSaver.js/issues/69 + // + // even though it has everything it fails to download anything + // that comes from the service worker..! + chunks.push(chunk) + return + } + + // is called when a new chunk of data is ready to be written + // to the underlying sink. It can return a promise to signal + // success or failure of the write operation. The stream + // implementation guarantees that this method will be called + // only after previous writes have succeeded, and never after + // close or abort is called. + + // TODO: Kind of important that service worker respond back when + // it has been written. Otherwise we can't handle backpressure + // EDIT: Transferable streams solves this... + try { + channel.port1.postMessage(chunk) + } catch(e){ + + }; + bytesWritten += chunk.length + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + close () { + if (useBlobFallback) { + const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + } else { + channel.port1.postMessage('end') + } + }, + abort () { + chunks = [] + channel.port1.postMessage('abort') + channel.port1.onmessage = null + setTimeout(function(channel){ + channel.port1.close() + channel.port2.close() + channel = null + },1300,channel); + } + }, opts.writableStrategy) + } + + const streamSaver = { + createWriteStream, + WritableStream: global.WritableStream || ponyfill.WritableStream, + supported: true, + version: { full: '2.0.7', major: 2, minor: 0, dot: 7 }, + mitm: './thirdparty/mitm.html?v=4' + } + + //console.log(streamSaver); + + /** + * create a hidden iframe and append it to the DOM (body) + * + * @param {string} src page to load + * @return {HTMLIFrameElement} page to load + */ + function makeIframe (src) { + if (!src) throw new Error('meh') + const iframe = document.createElement('iframe') + iframe.hidden = true + iframe.src = src + iframe.loaded = false + iframe.name = 'iframe' + iframe.isIframe = true + iframe.credentialless = true + iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) + iframe.addEventListener('load', () => { + iframe.loaded = true + }, { once: true }) + document.body.appendChild(iframe) + return iframe + } + + /** + * create a popup that simulates the basic things + * of what a iframe can do + * + * @param {string} src page to load + * @return {object} iframe like object + */ + function makePopup (src) { + const options = 'width=200,height=100' + const delegate = document.createDocumentFragment() + const popup = { + frame: global.open(src, 'popup', options), + loaded: false, + isIframe: false, + isPopup: true, + remove () { popup.frame.close() }, + addEventListener (...args) { delegate.addEventListener(...args) }, + dispatchEvent (...args) { delegate.dispatchEvent(...args) }, + removeEventListener (...args) { delegate.removeEventListener(...args) }, + postMessage (...args) { popup.frame.postMessage(...args) } + } + + const onReady = evt => { + if (evt.source === popup.frame) { + popup.loaded = true + global.removeEventListener('message', onReady) + popup.dispatchEvent(new Event('load')) + } + } + + global.addEventListener('message', onReady) + + return popup + } + + try { + // We can't look for service worker since it may still work on http + new Response(new ReadableStream()) + if (isSecureContext && !('serviceWorker' in navigator)) { + useBlobFallback = true + } + } catch (err) { + useBlobFallback = true + } + + //console.log("useBlobFallback: "+useBlobFallback); + + test(() => { + // Transferable stream was first enabled in chrome v73 behind a flag + const { readable } = new TransformStream() + const mc = new MessageChannel() + mc.port1.postMessage(readable, [readable]) + mc.port1.close() + mc.port2.close() + supportsTransferable = true + // Freeze TransformStream object (can only work with native) + Object.defineProperty(streamSaver, 'TransformStream', { + configurable: false, + writable: false, + value: TransformStream + }) + }) + + function loadTransporter () { + if (!mitmTransporter) { + mitmTransporter = isSecureContext + ? makeIframe(streamSaver.mitm) + : makePopup(streamSaver.mitm) + } + } + + return streamSaver +}; var streamSaver = streamSaverFunction(); \ No newline at end of file diff --git a/thirdparty/StreamSaver_legacy.js b/thirdparty/StreamSaver_legacy.js index 41860f3..89952ca 100644 --- a/thirdparty/StreamSaver_legacy.js +++ b/thirdparty/StreamSaver_legacy.js @@ -1,317 +1,317 @@ -/*! streamsaver. MIT License. Jimmy Wärting */ - -/* global chrome location ReadableStream define MessageChannel TransformStream */ - -function streamSaverFunction(){ - 'use strict' - - const global = typeof window === 'object' ? window : this; - if (!global.HTMLElement) console.warn('streamsaver is meant to run on browsers main thread') - - let mitmTransporter = null; - let supportsTransferable = false; - const test = fn => { try { fn() } catch (e) {} }; - const ponyfill = global.WebStreamsPolyfill || {}; - const isSecureContext = global.isSecureContext; - - //console.log(ponyfill); - //console.log(isSecureContext); - - // TODO: Must come up with a real detection test (#69) - let useBlobFallback = /constructor/i.test(global.HTMLElement) || !!global.safari || !!global.WebKitPoint; - - //console.log(useBlobFallback); - - const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style - ? 'iframe' - : 'navigate'; - - //console.log(downloadStrategy); - - function createWriteStream (filename, stopStream){ - //console.log("createWriteStream"); - let opts = { - size: null, - pathname: null, - writableStrategy: undefined, - readableStrategy: undefined - } - - let bytesWritten = 0 // by StreamSaver.js (not the service worker) - let downloadUrl = null - let channel = null - let ts = null - - if (!useBlobFallback) { - loadTransporter() - - channel = new MessageChannel() - - // Make filename RFC5987 compatible - filename = encodeURIComponent(filename.replace(/\//g, ':')) - .replace(/['()]/g, escape) - .replace(/\*/g, '%2A') - - const response = { - transferringReadable: supportsTransferable, - pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, - headers: { - 'Content-Type': 'application/octet-stream; charset=utf-8', - 'Content-Disposition': "attachment; filename*=UTF-8''" + filename - } - } - - if (opts.size) { - response.headers['Content-Length'] = opts.size - } - - const args = [ response, '*', [ channel.port2 ] ] - - if (supportsTransferable) { - const transformer = downloadStrategy === 'iframe' ? undefined : { - // This transformer & flush method is only used by insecure context. - transform (chunk, controller) { - if (!(chunk instanceof Uint8Array)) { - throw new TypeError('Can only write Uint8Arrays') - } - bytesWritten += chunk.length - controller.enqueue(chunk) - - if (downloadUrl) { - location.href = downloadUrl - downloadUrl = null - } - }, - flush () { - if (downloadUrl) { - location.href = downloadUrl - } - } - } - ts = new streamSaver.TransformStream( - transformer, - opts.writableStrategy, - opts.readableStrategy - ) - const readableStream = ts.readable - - channel.port1.postMessage({ readableStream }, [ readableStream ]) - } - - channel.port1.onmessage = evt => { - console.log(evt); - // Service worker sent us a link that we should open. - if (evt.data.download) { - // Special treatment for popup... - if (downloadStrategy === 'navigate') { - mitmTransporter.remove() - mitmTransporter = null - if (bytesWritten) { - location.href = evt.data.download - } else { - downloadUrl = evt.data.download - } - } else { - if (mitmTransporter.isPopup) { - mitmTransporter.remove() - mitmTransporter = null - // Special case for firefox, they can keep sw alive with fetch - if (downloadStrategy === 'iframe') { - makeIframe(streamSaver.mitm) - } - } - - // We never remove this iframes b/c it can interrupt saving - makeIframe(evt.data.download) - } - } else if (evt.data.abort) { - stopStream(false, true); - chunks = [] - channel.port1.postMessage('abort') //send back so controller is aborted - channel.port1.onmessage = null - - setTimeout(function(channel){ - channel.port1.close() - channel.port2.close() - channel = null - },1300,channel); - } - } - - if (mitmTransporter.loaded) { - mitmTransporter.postMessage(...args) - } else { - mitmTransporter.addEventListener('load', () => { - mitmTransporter.postMessage(...args) - }, { once: true }) - } - } - - let chunks = [] - - return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ - write (chunk) { - if (!(chunk instanceof Uint8Array)) { - throw new TypeError('Can only write Uint8Arrays') - } - if (useBlobFallback) { - // Safari... The new IE6 - // https://github.com/jimmywarting/StreamSaver.js/issues/69 - // - // even though it has everything it fails to download anything - // that comes from the service worker..! - chunks.push(chunk) - return - } - - // is called when a new chunk of data is ready to be written - // to the underlying sink. It can return a promise to signal - // success or failure of the write operation. The stream - // implementation guarantees that this method will be called - // only after previous writes have succeeded, and never after - // close or abort is called. - - // TODO: Kind of important that service worker respond back when - // it has been written. Otherwise we can't handle backpressure - // EDIT: Transferable streams solves this... - try { - channel.port1.postMessage(chunk) - } catch(e){ - - }; - bytesWritten += chunk.length - if (downloadUrl) { - location.href = downloadUrl - downloadUrl = null - } - }, - close () { - if (useBlobFallback) { - const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - link.click() - } else { - channel.port1.postMessage('end') - } - }, - abort () { - chunks = [] - channel.port1.postMessage('abort') - channel.port1.onmessage = null - setTimeout(function(channel){ - channel.port1.close() - channel.port2.close() - channel = null - },1300,channel); - } - }, opts.writableStrategy) - } - - const streamSaver = { - createWriteStream, - WritableStream: global.WritableStream || ponyfill.WritableStream, - supported: true, - version: { full: '2.0.7', major: 2, minor: 0, dot: 7 }, - mitm: './thirdparty/mitm.html?v=2' - } - - //console.log(streamSaver); - - /** - * create a hidden iframe and append it to the DOM (body) - * - * @param {string} src page to load - * @return {HTMLIFrameElement} page to load - */ - function makeIframe (src) { - if (!src) throw new Error('meh') - const iframe = document.createElement('iframe') - iframe.hidden = true - iframe.src = src - iframe.loaded = false - iframe.name = 'iframe' - iframe.isIframe = true - iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) - iframe.addEventListener('load', () => { - iframe.loaded = true - }, { once: true }) - document.body.appendChild(iframe) - return iframe - } - - /** - * create a popup that simulates the basic things - * of what a iframe can do - * - * @param {string} src page to load - * @return {object} iframe like object - */ - function makePopup (src) { - const options = 'width=200,height=100' - const delegate = document.createDocumentFragment() - const popup = { - frame: global.open(src, 'popup', options), - loaded: false, - isIframe: false, - isPopup: true, - remove () { popup.frame.close() }, - addEventListener (...args) { delegate.addEventListener(...args) }, - dispatchEvent (...args) { delegate.dispatchEvent(...args) }, - removeEventListener (...args) { delegate.removeEventListener(...args) }, - postMessage (...args) { popup.frame.postMessage(...args) } - } - - const onReady = evt => { - if (evt.source === popup.frame) { - popup.loaded = true - global.removeEventListener('message', onReady) - popup.dispatchEvent(new Event('load')) - } - } - - global.addEventListener('message', onReady) - - return popup - } - - try { - // We can't look for service worker since it may still work on http - new Response(new ReadableStream()) - if (isSecureContext && !('serviceWorker' in navigator)) { - useBlobFallback = true - } - } catch (err) { - useBlobFallback = true - } - - //console.log("useBlobFallback: "+useBlobFallback); - - test(() => { - // Transferable stream was first enabled in chrome v73 behind a flag - const { readable } = new TransformStream() - const mc = new MessageChannel() - mc.port1.postMessage(readable, [readable]) - mc.port1.close() - mc.port2.close() - supportsTransferable = true - // Freeze TransformStream object (can only work with native) - Object.defineProperty(streamSaver, 'TransformStream', { - configurable: false, - writable: false, - value: TransformStream - }) - }) - - function loadTransporter () { - if (!mitmTransporter) { - mitmTransporter = isSecureContext - ? makeIframe(streamSaver.mitm) - : makePopup(streamSaver.mitm) - } - } - - return streamSaver -}; +/*! streamsaver. MIT License. Jimmy Wärting */ + +/* global chrome location ReadableStream define MessageChannel TransformStream */ + +function streamSaverFunction(){ + 'use strict' + + const global = typeof window === 'object' ? window : this; + if (!global.HTMLElement) console.warn('streamsaver is meant to run on browsers main thread') + + let mitmTransporter = null; + let supportsTransferable = false; + const test = fn => { try { fn() } catch (e) {} }; + const ponyfill = global.WebStreamsPolyfill || {}; + const isSecureContext = global.isSecureContext; + + //console.log(ponyfill); + //console.log(isSecureContext); + + // TODO: Must come up with a real detection test (#69) + let useBlobFallback = /constructor/i.test(global.HTMLElement) || !!global.safari || !!global.WebKitPoint; + + //console.log(useBlobFallback); + + const downloadStrategy = isSecureContext || 'MozAppearance' in document.documentElement.style + ? 'iframe' + : 'navigate'; + + //console.log(downloadStrategy); + + function createWriteStream (filename, stopStream){ + //console.log("createWriteStream"); + let opts = { + size: null, + pathname: null, + writableStrategy: undefined, + readableStrategy: undefined + } + + let bytesWritten = 0 // by StreamSaver.js (not the service worker) + let downloadUrl = null + let channel = null + let ts = null + + if (!useBlobFallback) { + loadTransporter() + + channel = new MessageChannel() + + // Make filename RFC5987 compatible + filename = encodeURIComponent(filename.replace(/\//g, ':')) + .replace(/['()]/g, escape) + .replace(/\*/g, '%2A') + + const response = { + transferringReadable: supportsTransferable, + pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename, + headers: { + 'Content-Type': 'application/octet-stream; charset=utf-8', + 'Content-Disposition': "attachment; filename*=UTF-8''" + filename + } + } + + if (opts.size) { + response.headers['Content-Length'] = opts.size + } + + const args = [ response, '*', [ channel.port2 ] ] + + if (supportsTransferable) { + const transformer = downloadStrategy === 'iframe' ? undefined : { + // This transformer & flush method is only used by insecure context. + transform (chunk, controller) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('Can only write Uint8Arrays') + } + bytesWritten += chunk.length + controller.enqueue(chunk) + + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + flush () { + if (downloadUrl) { + location.href = downloadUrl + } + } + } + ts = new streamSaver.TransformStream( + transformer, + opts.writableStrategy, + opts.readableStrategy + ) + const readableStream = ts.readable + + channel.port1.postMessage({ readableStream }, [ readableStream ]) + } + + channel.port1.onmessage = evt => { + console.log(evt); + // Service worker sent us a link that we should open. + if (evt.data.download) { + // Special treatment for popup... + if (downloadStrategy === 'navigate') { + mitmTransporter.remove() + mitmTransporter = null + if (bytesWritten) { + location.href = evt.data.download + } else { + downloadUrl = evt.data.download + } + } else { + if (mitmTransporter.isPopup) { + mitmTransporter.remove() + mitmTransporter = null + // Special case for firefox, they can keep sw alive with fetch + if (downloadStrategy === 'iframe') { + makeIframe(streamSaver.mitm) + } + } + + // We never remove this iframes b/c it can interrupt saving + makeIframe(evt.data.download) + } + } else if (evt.data.abort) { + stopStream(false, true); + chunks = [] + channel.port1.postMessage('abort') //send back so controller is aborted + channel.port1.onmessage = null + + setTimeout(function(channel){ + channel.port1.close() + channel.port2.close() + channel = null + },1300,channel); + } + } + + if (mitmTransporter.loaded) { + mitmTransporter.postMessage(...args) + } else { + mitmTransporter.addEventListener('load', () => { + mitmTransporter.postMessage(...args) + }, { once: true }) + } + } + + let chunks = [] + + return (!useBlobFallback && ts && ts.writable) || new streamSaver.WritableStream({ + write (chunk) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('Can only write Uint8Arrays') + } + if (useBlobFallback) { + // Safari... The new IE6 + // https://github.com/jimmywarting/StreamSaver.js/issues/69 + // + // even though it has everything it fails to download anything + // that comes from the service worker..! + chunks.push(chunk) + return + } + + // is called when a new chunk of data is ready to be written + // to the underlying sink. It can return a promise to signal + // success or failure of the write operation. The stream + // implementation guarantees that this method will be called + // only after previous writes have succeeded, and never after + // close or abort is called. + + // TODO: Kind of important that service worker respond back when + // it has been written. Otherwise we can't handle backpressure + // EDIT: Transferable streams solves this... + try { + channel.port1.postMessage(chunk) + } catch(e){ + + }; + bytesWritten += chunk.length + if (downloadUrl) { + location.href = downloadUrl + downloadUrl = null + } + }, + close () { + if (useBlobFallback) { + const blob = new Blob(chunks, { type: 'application/octet-stream; charset=utf-8' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + } else { + channel.port1.postMessage('end') + } + }, + abort () { + chunks = [] + channel.port1.postMessage('abort') + channel.port1.onmessage = null + setTimeout(function(channel){ + channel.port1.close() + channel.port2.close() + channel = null + },1300,channel); + } + }, opts.writableStrategy) + } + + const streamSaver = { + createWriteStream, + WritableStream: global.WritableStream || ponyfill.WritableStream, + supported: true, + version: { full: '2.0.7', major: 2, minor: 0, dot: 7 }, + mitm: './thirdparty/mitm.html?v=2' + } + + //console.log(streamSaver); + + /** + * create a hidden iframe and append it to the DOM (body) + * + * @param {string} src page to load + * @return {HTMLIFrameElement} page to load + */ + function makeIframe (src) { + if (!src) throw new Error('meh') + const iframe = document.createElement('iframe') + iframe.hidden = true + iframe.src = src + iframe.loaded = false + iframe.name = 'iframe' + iframe.isIframe = true + iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args) + iframe.addEventListener('load', () => { + iframe.loaded = true + }, { once: true }) + document.body.appendChild(iframe) + return iframe + } + + /** + * create a popup that simulates the basic things + * of what a iframe can do + * + * @param {string} src page to load + * @return {object} iframe like object + */ + function makePopup (src) { + const options = 'width=200,height=100' + const delegate = document.createDocumentFragment() + const popup = { + frame: global.open(src, 'popup', options), + loaded: false, + isIframe: false, + isPopup: true, + remove () { popup.frame.close() }, + addEventListener (...args) { delegate.addEventListener(...args) }, + dispatchEvent (...args) { delegate.dispatchEvent(...args) }, + removeEventListener (...args) { delegate.removeEventListener(...args) }, + postMessage (...args) { popup.frame.postMessage(...args) } + } + + const onReady = evt => { + if (evt.source === popup.frame) { + popup.loaded = true + global.removeEventListener('message', onReady) + popup.dispatchEvent(new Event('load')) + } + } + + global.addEventListener('message', onReady) + + return popup + } + + try { + // We can't look for service worker since it may still work on http + new Response(new ReadableStream()) + if (isSecureContext && !('serviceWorker' in navigator)) { + useBlobFallback = true + } + } catch (err) { + useBlobFallback = true + } + + //console.log("useBlobFallback: "+useBlobFallback); + + test(() => { + // Transferable stream was first enabled in chrome v73 behind a flag + const { readable } = new TransformStream() + const mc = new MessageChannel() + mc.port1.postMessage(readable, [readable]) + mc.port1.close() + mc.port2.close() + supportsTransferable = true + // Freeze TransformStream object (can only work with native) + Object.defineProperty(streamSaver, 'TransformStream', { + configurable: false, + writable: false, + value: TransformStream + }) + }) + + function loadTransporter () { + if (!mitmTransporter) { + mitmTransporter = isSecureContext + ? makeIframe(streamSaver.mitm) + : makePopup(streamSaver.mitm) + } + } + + return streamSaver +}; var streamSaver = streamSaverFunction(); \ No newline at end of file diff --git a/thirdparty/mitm.html b/thirdparty/mitm.html index efe50bb..1d37c0f 100644 --- a/thirdparty/mitm.html +++ b/thirdparty/mitm.html @@ -43,11 +43,17 @@ function registerWorker() { return navigator.serviceWorker.getRegistration('./').then(swReg => { return swReg || navigator.serviceWorker.register('sw.js?v=2', { scope: './' }) }).then(swReg => { + if (!swReg) { + throw new Error('[StreamSaver] Service worker registration failed') + } const swRegTmp = swReg.installing || swReg.waiting scope = swReg.scope return (sw = swReg.active) || new Promise(resolve => { + if (!swRegTmp) { + throw new Error('[StreamSaver] Service worker is not installing') + } swRegTmp.addEventListener('statechange', fn = () => { if (swRegTmp.state === 'activated') { swRegTmp.removeEventListener('statechange', fn) @@ -157,6 +163,16 @@ if (navigator.serviceWorker) { registerWorker().then(() => { window.onmessage = onMessage messages.forEach(window.onmessage) + }).catch(err => { + try { + if (window.parent) { + window.parent.postMessage({ + streamSaverError: true, + reason: err && err.message ? err.message : 'StreamSaver service worker failed' + }, '*') + } + } catch (e) {} + console.error(err) }) } diff --git a/thirdparty/video.js b/thirdparty/video.js new file mode 100644 index 0000000..e4fed55 --- /dev/null +++ b/thirdparty/video.js @@ -0,0 +1,55669 @@ +/** + * @license + * Video.js 7.7.4 + * Copyright Brightcove, Inc. + * Available under Apache License Version 2.0 + * + * + * Includes vtt.js + * Available under Apache License Version 2.0 + * + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('global/window'), require('global/document')) : + typeof define === 'function' && define.amd ? define(['global/window', 'global/document'], factory) : + (global = global || self, global.videojs = factory(global.window, global.document)); +}(this, function (window$1, document) { 'use strict'; + + window$1 = window$1 && window$1.hasOwnProperty('default') ? window$1['default'] : window$1; + document = document && document.hasOwnProperty('default') ? document['default'] : document; + + var version = "7.7.4"; + + /** + * @file create-logger.js + * @module create-logger + */ + + var history = []; + /** + * Log messages to the console and history based on the type of message + * + * @private + * @param {string} type + * The name of the console method to use. + * + * @param {Array} args + * The arguments to be passed to the matching console method. + */ + + var LogByTypeFactory = function LogByTypeFactory(name, log) { + return function (type, level, args) { + var lvl = log.levels[level]; + var lvlRegExp = new RegExp("^(" + lvl + ")$"); + + if (type !== 'log') { + // Add the type to the front of the message when it's not "log". + args.unshift(type.toUpperCase() + ':'); + } // Add console prefix after adding to history. + + + args.unshift(name + ':'); // Add a clone of the args at this point to history. + + if (history) { + history.push([].concat(args)); // only store 1000 history entries + + var splice = history.length - 1000; + history.splice(0, splice > 0 ? splice : 0); + } // If there's no console then don't try to output messages, but they will + // still be stored in history. + + + if (!window$1.console) { + return; + } // Was setting these once outside of this function, but containing them + // in the function makes it easier to test cases where console doesn't exist + // when the module is executed. + + + var fn = window$1.console[type]; + + if (!fn && type === 'debug') { + // Certain browsers don't have support for console.debug. For those, we + // should default to the closest comparable log. + fn = window$1.console.info || window$1.console.log; + } // Bail out if there's no console or if this type is not allowed by the + // current logging level. + + + if (!fn || !lvl || !lvlRegExp.test(type)) { + return; + } + + fn[Array.isArray(args) ? 'apply' : 'call'](window$1.console, args); + }; + }; + + function createLogger(name) { + // This is the private tracking variable for logging level. + var level = 'info'; // the curried logByType bound to the specific log and history + + var logByType; + /** + * Logs plain debug messages. Similar to `console.log`. + * + * Due to [limitations](https://github.com/jsdoc3/jsdoc/issues/955#issuecomment-313829149) + * of our JSDoc template, we cannot properly document this as both a function + * and a namespace, so its function signature is documented here. + * + * #### Arguments + * ##### *args + * Mixed[] + * + * Any combination of values that could be passed to `console.log()`. + * + * #### Return Value + * + * `undefined` + * + * @namespace + * @param {Mixed[]} args + * One or more messages or objects that should be logged. + */ + + var log = function log() { + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + logByType('log', level, args); + }; // This is the logByType helper that the logging methods below use + + + logByType = LogByTypeFactory(name, log); + /** + * Create a new sublogger which chains the old name to the new name. + * + * For example, doing `videojs.log.createLogger('player')` and then using that logger will log the following: + * ```js + * mylogger('foo'); + * // > VIDEOJS: player: foo + * ``` + * + * @param {string} name + * The name to add call the new logger + * @return {Object} + */ + + log.createLogger = function (subname) { + return createLogger(name + ': ' + subname); + }; + /** + * Enumeration of available logging levels, where the keys are the level names + * and the values are `|`-separated strings containing logging methods allowed + * in that logging level. These strings are used to create a regular expression + * matching the function name being called. + * + * Levels provided by Video.js are: + * + * - `off`: Matches no calls. Any value that can be cast to `false` will have + * this effect. The most restrictive. + * - `all`: Matches only Video.js-provided functions (`debug`, `log`, + * `log.warn`, and `log.error`). + * - `debug`: Matches `log.debug`, `log`, `log.warn`, and `log.error` calls. + * - `info` (default): Matches `log`, `log.warn`, and `log.error` calls. + * - `warn`: Matches `log.warn` and `log.error` calls. + * - `error`: Matches only `log.error` calls. + * + * @type {Object} + */ + + + log.levels = { + all: 'debug|log|warn|error', + off: '', + debug: 'debug|log|warn|error', + info: 'log|warn|error', + warn: 'warn|error', + error: 'error', + DEFAULT: level + }; + /** + * Get or set the current logging level. + * + * If a string matching a key from {@link module:log.levels} is provided, acts + * as a setter. + * + * @param {string} [lvl] + * Pass a valid level to set a new logging level. + * + * @return {string} + * The current logging level. + */ + + log.level = function (lvl) { + if (typeof lvl === 'string') { + if (!log.levels.hasOwnProperty(lvl)) { + throw new Error("\"" + lvl + "\" in not a valid log level"); + } + + level = lvl; + } + + return level; + }; + /** + * Returns an array containing everything that has been logged to the history. + * + * This array is a shallow clone of the internal history record. However, its + * contents are _not_ cloned; so, mutating objects inside this array will + * mutate them in history. + * + * @return {Array} + */ + + + log.history = function () { + return history ? [].concat(history) : []; + }; + /** + * Allows you to filter the history by the given logger name + * + * @param {string} fname + * The name to filter by + * + * @return {Array} + * The filtered list to return + */ + + + log.history.filter = function (fname) { + return (history || []).filter(function (historyItem) { + // if the first item in each historyItem includes `fname`, then it's a match + return new RegExp(".*" + fname + ".*").test(historyItem[0]); + }); + }; + /** + * Clears the internal history tracking, but does not prevent further history + * tracking. + */ + + + log.history.clear = function () { + if (history) { + history.length = 0; + } + }; + /** + * Disable history tracking if it is currently enabled. + */ + + + log.history.disable = function () { + if (history !== null) { + history.length = 0; + history = null; + } + }; + /** + * Enable history tracking if it is currently disabled. + */ + + + log.history.enable = function () { + if (history === null) { + history = []; + } + }; + /** + * Logs error messages. Similar to `console.error`. + * + * @param {Mixed[]} args + * One or more messages or objects that should be logged as an error + */ + + + log.error = function () { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + return logByType('error', level, args); + }; + /** + * Logs warning messages. Similar to `console.warn`. + * + * @param {Mixed[]} args + * One or more messages or objects that should be logged as a warning. + */ + + + log.warn = function () { + for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { + args[_key3] = arguments[_key3]; + } + + return logByType('warn', level, args); + }; + /** + * Logs debug messages. Similar to `console.debug`, but may also act as a comparable + * log if `console.debug` is not available + * + * @param {Mixed[]} args + * One or more messages or objects that should be logged as debug. + */ + + + log.debug = function () { + for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + + return logByType('debug', level, args); + }; + + return log; + } + + /** + * @file log.js + * @module log + */ + var log = createLogger('VIDEOJS'); + var createLogger$1 = log.createLogger; + + function createCommonjsModule(fn, module) { + return module = { exports: {} }, fn(module, module.exports), module.exports; + } + + var _extends_1 = createCommonjsModule(function (module) { + function _extends() { + module.exports = _extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; + }; + + return _extends.apply(this, arguments); + } + + module.exports = _extends; + }); + + /** + * @file obj.js + * @module obj + */ + + /** + * @callback obj:EachCallback + * + * @param {Mixed} value + * The current key for the object that is being iterated over. + * + * @param {string} key + * The current key-value for object that is being iterated over + */ + + /** + * @callback obj:ReduceCallback + * + * @param {Mixed} accum + * The value that is accumulating over the reduce loop. + * + * @param {Mixed} value + * The current key for the object that is being iterated over. + * + * @param {string} key + * The current key-value for object that is being iterated over + * + * @return {Mixed} + * The new accumulated value. + */ + var toString = Object.prototype.toString; + /** + * Get the keys of an Object + * + * @param {Object} + * The Object to get the keys from + * + * @return {string[]} + * An array of the keys from the object. Returns an empty array if the + * object passed in was invalid or had no keys. + * + * @private + */ + + var keys = function keys(object) { + return isObject(object) ? Object.keys(object) : []; + }; + /** + * Array-like iteration for objects. + * + * @param {Object} object + * The object to iterate over + * + * @param {obj:EachCallback} fn + * The callback function which is called for each key in the object. + */ + + + function each(object, fn) { + keys(object).forEach(function (key) { + return fn(object[key], key); + }); + } + /** + * Array-like reduce for objects. + * + * @param {Object} object + * The Object that you want to reduce. + * + * @param {Function} fn + * A callback function which is called for each key in the object. It + * receives the accumulated value and the per-iteration value and key + * as arguments. + * + * @param {Mixed} [initial = 0] + * Starting value + * + * @return {Mixed} + * The final accumulated value. + */ + + function reduce(object, fn, initial) { + if (initial === void 0) { + initial = 0; + } + + return keys(object).reduce(function (accum, key) { + return fn(accum, object[key], key); + }, initial); + } + /** + * Object.assign-style object shallow merge/extend. + * + * @param {Object} target + * @param {Object} ...sources + * @return {Object} + */ + + function assign(target) { + for (var _len = arguments.length, sources = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + sources[_key - 1] = arguments[_key]; + } + + if (Object.assign) { + return _extends_1.apply(void 0, [target].concat(sources)); + } + + sources.forEach(function (source) { + if (!source) { + return; + } + + each(source, function (value, key) { + target[key] = value; + }); + }); + return target; + } + /** + * Returns whether a value is an object of any kind - including DOM nodes, + * arrays, regular expressions, etc. Not functions, though. + * + * This avoids the gotcha where using `typeof` on a `null` value + * results in `'object'`. + * + * @param {Object} value + * @return {boolean} + */ + + function isObject(value) { + return !!value && typeof value === 'object'; + } + /** + * Returns whether an object appears to be a "plain" object - that is, a + * direct instance of `Object`. + * + * @param {Object} value + * @return {boolean} + */ + + function isPlain(value) { + return isObject(value) && toString.call(value) === '[object Object]' && value.constructor === Object; + } + + /** + * @file computed-style.js + * @module computed-style + */ + /** + * A safe getComputedStyle. + * + * This is needed because in Firefox, if the player is loaded in an iframe with + * `display:none`, then `getComputedStyle` returns `null`, so, we do a + * null-check to make sure that the player doesn't break in these cases. + * + * @function + * @param {Element} el + * The element you want the computed style of + * + * @param {string} prop + * The property name you want + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397 + */ + + function computedStyle(el, prop) { + if (!el || !prop) { + return ''; + } + + if (typeof window$1.getComputedStyle === 'function') { + var computedStyleValue = window$1.getComputedStyle(el); + return computedStyleValue ? computedStyleValue.getPropertyValue(prop) || computedStyleValue[prop] : ''; + } + + return ''; + } + + /** + * @file dom.js + * @module dom + */ + /** + * Detect if a value is a string with any non-whitespace characters. + * + * @private + * @param {string} str + * The string to check + * + * @return {boolean} + * Will be `true` if the string is non-blank, `false` otherwise. + * + */ + + function isNonBlankString(str) { + // we use str.trim as it will trim any whitespace characters + // from the front or back of non-whitespace characters. aka + // Any string that contains non-whitespace characters will + // still contain them after `trim` but whitespace only strings + // will have a length of 0, failing this check. + return typeof str === 'string' && Boolean(str.trim()); + } + /** + * Throws an error if the passed string has whitespace. This is used by + * class methods to be relatively consistent with the classList API. + * + * @private + * @param {string} str + * The string to check for whitespace. + * + * @throws {Error} + * Throws an error if there is whitespace in the string. + */ + + + function throwIfWhitespace(str) { + // str.indexOf instead of regex because str.indexOf is faster performance wise. + if (str.indexOf(' ') >= 0) { + throw new Error('class has illegal whitespace characters'); + } + } + /** + * Produce a regular expression for matching a className within an elements className. + * + * @private + * @param {string} className + * The className to generate the RegExp for. + * + * @return {RegExp} + * The RegExp that will check for a specific `className` in an elements + * className. + */ + + + function classRegExp(className) { + return new RegExp('(^|\\s)' + className + '($|\\s)'); + } + /** + * Whether the current DOM interface appears to be real (i.e. not simulated). + * + * @return {boolean} + * Will be `true` if the DOM appears to be real, `false` otherwise. + */ + + + function isReal() { + // Both document and window will never be undefined thanks to `global`. + return document === window$1.document; + } + /** + * Determines, via duck typing, whether or not a value is a DOM element. + * + * @param {Mixed} value + * The value to check. + * + * @return {boolean} + * Will be `true` if the value is a DOM element, `false` otherwise. + */ + + function isEl(value) { + return isObject(value) && value.nodeType === 1; + } + /** + * Determines if the current DOM is embedded in an iframe. + * + * @return {boolean} + * Will be `true` if the DOM is embedded in an iframe, `false` + * otherwise. + */ + + function isInFrame() { + // We need a try/catch here because Safari will throw errors when attempting + // to get either `parent` or `self` + try { + return window$1.parent !== window$1.self; + } catch (x) { + return true; + } + } + /** + * Creates functions to query the DOM using a given method. + * + * @private + * @param {string} method + * The method to create the query with. + * + * @return {Function} + * The query method + */ + + function createQuerier(method) { + return function (selector, context) { + if (!isNonBlankString(selector)) { + return document[method](null); + } + + if (isNonBlankString(context)) { + context = document.querySelector(context); + } + + var ctx = isEl(context) ? context : document; + return ctx[method] && ctx[method](selector); + }; + } + /** + * Creates an element and applies properties, attributes, and inserts content. + * + * @param {string} [tagName='div'] + * Name of tag to be created. + * + * @param {Object} [properties={}] + * Element properties to be applied. + * + * @param {Object} [attributes={}] + * Element attributes to be applied. + * + * @param {module:dom~ContentDescriptor} content + * A content descriptor object. + * + * @return {Element} + * The element that was created. + */ + + + function createEl(tagName, properties, attributes, content) { + if (tagName === void 0) { + tagName = 'div'; + } + + if (properties === void 0) { + properties = {}; + } + + if (attributes === void 0) { + attributes = {}; + } + + var el = document.createElement(tagName); + Object.getOwnPropertyNames(properties).forEach(function (propName) { + var val = properties[propName]; // See #2176 + // We originally were accepting both properties and attributes in the + // same object, but that doesn't work so well. + + if (propName.indexOf('aria-') !== -1 || propName === 'role' || propName === 'type') { + log.warn('Setting attributes in the second argument of createEl()\n' + 'has been deprecated. Use the third argument instead.\n' + ("createEl(type, properties, attributes). Attempting to set " + propName + " to " + val + ".")); + el.setAttribute(propName, val); // Handle textContent since it's not supported everywhere and we have a + // method for it. + } else if (propName === 'textContent') { + textContent(el, val); + } else if (el[propName] !== val) { + el[propName] = val; + } + }); + Object.getOwnPropertyNames(attributes).forEach(function (attrName) { + el.setAttribute(attrName, attributes[attrName]); + }); + + if (content) { + appendContent(el, content); + } + + return el; + } + /** + * Injects text into an element, replacing any existing contents entirely. + * + * @param {Element} el + * The element to add text content into + * + * @param {string} text + * The text content to add. + * + * @return {Element} + * The element with added text content. + */ + + function textContent(el, text) { + if (typeof el.textContent === 'undefined') { + el.innerText = text; + } else { + el.textContent = text; + } + + return el; + } + /** + * Insert an element as the first child node of another + * + * @param {Element} child + * Element to insert + * + * @param {Element} parent + * Element to insert child into + */ + + function prependTo(child, parent) { + if (parent.firstChild) { + parent.insertBefore(child, parent.firstChild); + } else { + parent.appendChild(child); + } + } + /** + * Check if an element has a class name. + * + * @param {Element} element + * Element to check + * + * @param {string} classToCheck + * Class name to check for + * + * @return {boolean} + * Will be `true` if the element has a class, `false` otherwise. + * + * @throws {Error} + * Throws an error if `classToCheck` has white space. + */ + + function hasClass(element, classToCheck) { + throwIfWhitespace(classToCheck); + + if (element.classList) { + return element.classList.contains(classToCheck); + } + + return classRegExp(classToCheck).test(element.className); + } + /** + * Add a class name to an element. + * + * @param {Element} element + * Element to add class name to. + * + * @param {string} classToAdd + * Class name to add. + * + * @return {Element} + * The DOM element with the added class name. + */ + + function addClass(element, classToAdd) { + if (element.classList) { + element.classList.add(classToAdd); // Don't need to `throwIfWhitespace` here because `hasElClass` will do it + // in the case of classList not being supported. + } else if (!hasClass(element, classToAdd)) { + element.className = (element.className + ' ' + classToAdd).trim(); + } + + return element; + } + /** + * Remove a class name from an element. + * + * @param {Element} element + * Element to remove a class name from. + * + * @param {string} classToRemove + * Class name to remove + * + * @return {Element} + * The DOM element with class name removed. + */ + + function removeClass(element, classToRemove) { + if (element.classList) { + element.classList.remove(classToRemove); + } else { + throwIfWhitespace(classToRemove); + element.className = element.className.split(/\s+/).filter(function (c) { + return c !== classToRemove; + }).join(' '); + } + + return element; + } + /** + * The callback definition for toggleClass. + * + * @callback module:dom~PredicateCallback + * @param {Element} element + * The DOM element of the Component. + * + * @param {string} classToToggle + * The `className` that wants to be toggled + * + * @return {boolean|undefined} + * If `true` is returned, the `classToToggle` will be added to the + * `element`. If `false`, the `classToToggle` will be removed from + * the `element`. If `undefined`, the callback will be ignored. + */ + + /** + * Adds or removes a class name to/from an element depending on an optional + * condition or the presence/absence of the class name. + * + * @param {Element} element + * The element to toggle a class name on. + * + * @param {string} classToToggle + * The class that should be toggled. + * + * @param {boolean|module:dom~PredicateCallback} [predicate] + * See the return value for {@link module:dom~PredicateCallback} + * + * @return {Element} + * The element with a class that has been toggled. + */ + + function toggleClass(element, classToToggle, predicate) { + // This CANNOT use `classList` internally because IE11 does not support the + // second parameter to the `classList.toggle()` method! Which is fine because + // `classList` will be used by the add/remove functions. + var has = hasClass(element, classToToggle); + + if (typeof predicate === 'function') { + predicate = predicate(element, classToToggle); + } + + if (typeof predicate !== 'boolean') { + predicate = !has; + } // If the necessary class operation matches the current state of the + // element, no action is required. + + + if (predicate === has) { + return; + } + + if (predicate) { + addClass(element, classToToggle); + } else { + removeClass(element, classToToggle); + } + + return element; + } + /** + * Apply attributes to an HTML element. + * + * @param {Element} el + * Element to add attributes to. + * + * @param {Object} [attributes] + * Attributes to be applied. + */ + + function setAttributes(el, attributes) { + Object.getOwnPropertyNames(attributes).forEach(function (attrName) { + var attrValue = attributes[attrName]; + + if (attrValue === null || typeof attrValue === 'undefined' || attrValue === false) { + el.removeAttribute(attrName); + } else { + el.setAttribute(attrName, attrValue === true ? '' : attrValue); + } + }); + } + /** + * Get an element's attribute values, as defined on the HTML tag. + * + * Attributes are not the same as properties. They're defined on the tag + * or with setAttribute. + * + * @param {Element} tag + * Element from which to get tag attributes. + * + * @return {Object} + * All attributes of the element. Boolean attributes will be `true` or + * `false`, others will be strings. + */ + + function getAttributes(tag) { + var obj = {}; // known boolean attributes + // we can check for matching boolean properties, but not all browsers + // and not all tags know about these attributes, so, we still want to check them manually + + var knownBooleans = ',' + 'autoplay,controls,playsinline,loop,muted,default,defaultMuted' + ','; + + if (tag && tag.attributes && tag.attributes.length > 0) { + var attrs = tag.attributes; + + for (var i = attrs.length - 1; i >= 0; i--) { + var attrName = attrs[i].name; + var attrVal = attrs[i].value; // check for known booleans + // the matching element property will return a value for typeof + + if (typeof tag[attrName] === 'boolean' || knownBooleans.indexOf(',' + attrName + ',') !== -1) { + // the value of an included boolean attribute is typically an empty + // string ('') which would equal false if we just check for a false value. + // we also don't want support bad code like autoplay='false' + attrVal = attrVal !== null ? true : false; + } + + obj[attrName] = attrVal; + } + } + + return obj; + } + /** + * Get the value of an element's attribute. + * + * @param {Element} el + * A DOM element. + * + * @param {string} attribute + * Attribute to get the value of. + * + * @return {string} + * The value of the attribute. + */ + + function getAttribute(el, attribute) { + return el.getAttribute(attribute); + } + /** + * Set the value of an element's attribute. + * + * @param {Element} el + * A DOM element. + * + * @param {string} attribute + * Attribute to set. + * + * @param {string} value + * Value to set the attribute to. + */ + + function setAttribute(el, attribute, value) { + el.setAttribute(attribute, value); + } + /** + * Remove an element's attribute. + * + * @param {Element} el + * A DOM element. + * + * @param {string} attribute + * Attribute to remove. + */ + + function removeAttribute(el, attribute) { + el.removeAttribute(attribute); + } + /** + * Attempt to block the ability to select text. + */ + + function blockTextSelection() { + document.body.focus(); + + document.onselectstart = function () { + return false; + }; + } + /** + * Turn off text selection blocking. + */ + + function unblockTextSelection() { + document.onselectstart = function () { + return true; + }; + } + /** + * Identical to the native `getBoundingClientRect` function, but ensures that + * the method is supported at all (it is in all browsers we claim to support) + * and that the element is in the DOM before continuing. + * + * This wrapper function also shims properties which are not provided by some + * older browsers (namely, IE8). + * + * Additionally, some browsers do not support adding properties to a + * `ClientRect`/`DOMRect` object; so, we shallow-copy it with the standard + * properties (except `x` and `y` which are not widely supported). This helps + * avoid implementations where keys are non-enumerable. + * + * @param {Element} el + * Element whose `ClientRect` we want to calculate. + * + * @return {Object|undefined} + * Always returns a plain object - or `undefined` if it cannot. + */ + + function getBoundingClientRect(el) { + if (el && el.getBoundingClientRect && el.parentNode) { + var rect = el.getBoundingClientRect(); + var result = {}; + ['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(function (k) { + if (rect[k] !== undefined) { + result[k] = rect[k]; + } + }); + + if (!result.height) { + result.height = parseFloat(computedStyle(el, 'height')); + } + + if (!result.width) { + result.width = parseFloat(computedStyle(el, 'width')); + } + + return result; + } + } + /** + * Represents the position of a DOM element on the page. + * + * @typedef {Object} module:dom~Position + * + * @property {number} left + * Pixels to the left. + * + * @property {number} top + * Pixels from the top. + */ + + /** + * Get the position of an element in the DOM. + * + * Uses `getBoundingClientRect` technique from John Resig. + * + * @see http://ejohn.org/blog/getboundingclientrect-is-awesome/ + * + * @param {Element} el + * Element from which to get offset. + * + * @return {module:dom~Position} + * The position of the element that was passed in. + */ + + function findPosition(el) { + var box; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { + left: 0, + top: 0 + }; + } + + var docEl = document.documentElement; + var body = document.body; + var clientLeft = docEl.clientLeft || body.clientLeft || 0; + var scrollLeft = window$1.pageXOffset || body.scrollLeft; + var left = box.left + scrollLeft - clientLeft; + var clientTop = docEl.clientTop || body.clientTop || 0; + var scrollTop = window$1.pageYOffset || body.scrollTop; + var top = box.top + scrollTop - clientTop; // Android sometimes returns slightly off decimal values, so need to round + + return { + left: Math.round(left), + top: Math.round(top) + }; + } + /** + * Represents x and y coordinates for a DOM element or mouse pointer. + * + * @typedef {Object} module:dom~Coordinates + * + * @property {number} x + * x coordinate in pixels + * + * @property {number} y + * y coordinate in pixels + */ + + /** + * Get the pointer position within an element. + * + * The base on the coordinates are the bottom left of the element. + * + * @param {Element} el + * Element on which to get the pointer position on. + * + * @param {EventTarget~Event} event + * Event object. + * + * @return {module:dom~Coordinates} + * A coordinates object corresponding to the mouse position. + * + */ + + function getPointerPosition(el, event) { + var position = {}; + var box = findPosition(el); + var boxW = el.offsetWidth; + var boxH = el.offsetHeight; + var boxY = box.top; + var boxX = box.left; + var pageY = event.pageY; + var pageX = event.pageX; + + if (event.changedTouches) { + pageX = event.changedTouches[0].pageX; + pageY = event.changedTouches[0].pageY; + } + + position.y = Math.max(0, Math.min(1, (boxY - pageY + boxH) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + return position; + } + /** + * Determines, via duck typing, whether or not a value is a text node. + * + * @param {Mixed} value + * Check if this value is a text node. + * + * @return {boolean} + * Will be `true` if the value is a text node, `false` otherwise. + */ + + function isTextNode(value) { + return isObject(value) && value.nodeType === 3; + } + /** + * Empties the contents of an element. + * + * @param {Element} el + * The element to empty children from + * + * @return {Element} + * The element with no children + */ + + function emptyEl(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } + + return el; + } + /** + * This is a mixed value that describes content to be injected into the DOM + * via some method. It can be of the following types: + * + * Type | Description + * -----------|------------- + * `string` | The value will be normalized into a text node. + * `Element` | The value will be accepted as-is. + * `TextNode` | The value will be accepted as-is. + * `Array` | A one-dimensional array of strings, elements, text nodes, or functions. These functions should return a string, element, or text node (any other return value, like an array, will be ignored). + * `Function` | A function, which is expected to return a string, element, text node, or array - any of the other possible values described above. This means that a content descriptor could be a function that returns an array of functions, but those second-level functions must return strings, elements, or text nodes. + * + * @typedef {string|Element|TextNode|Array|Function} module:dom~ContentDescriptor + */ + + /** + * Normalizes content for eventual insertion into the DOM. + * + * This allows a wide range of content definition methods, but helps protect + * from falling into the trap of simply writing to `innerHTML`, which could + * be an XSS concern. + * + * The content for an element can be passed in multiple types and + * combinations, whose behavior is as follows: + * + * @param {module:dom~ContentDescriptor} content + * A content descriptor value. + * + * @return {Array} + * All of the content that was passed in, normalized to an array of + * elements or text nodes. + */ + + function normalizeContent(content) { + // First, invoke content if it is a function. If it produces an array, + // that needs to happen before normalization. + if (typeof content === 'function') { + content = content(); + } // Next up, normalize to an array, so one or many items can be normalized, + // filtered, and returned. + + + return (Array.isArray(content) ? content : [content]).map(function (value) { + // First, invoke value if it is a function to produce a new value, + // which will be subsequently normalized to a Node of some kind. + if (typeof value === 'function') { + value = value(); + } + + if (isEl(value) || isTextNode(value)) { + return value; + } + + if (typeof value === 'string' && /\S/.test(value)) { + return document.createTextNode(value); + } + }).filter(function (value) { + return value; + }); + } + /** + * Normalizes and appends content to an element. + * + * @param {Element} el + * Element to append normalized content to. + * + * @param {module:dom~ContentDescriptor} content + * A content descriptor value. + * + * @return {Element} + * The element with appended normalized content. + */ + + function appendContent(el, content) { + normalizeContent(content).forEach(function (node) { + return el.appendChild(node); + }); + return el; + } + /** + * Normalizes and inserts content into an element; this is identical to + * `appendContent()`, except it empties the element first. + * + * @param {Element} el + * Element to insert normalized content into. + * + * @param {module:dom~ContentDescriptor} content + * A content descriptor value. + * + * @return {Element} + * The element with inserted normalized content. + */ + + function insertContent(el, content) { + return appendContent(emptyEl(el), content); + } + /** + * Check if an event was a single left click. + * + * @param {EventTarget~Event} event + * Event object. + * + * @return {boolean} + * Will be `true` if a single left click, `false` otherwise. + */ + + function isSingleLeftClick(event) { + // Note: if you create something draggable, be sure to + // call it on both `mousedown` and `mousemove` event, + // otherwise `mousedown` should be enough for a button + if (event.button === undefined && event.buttons === undefined) { + // Why do we need `buttons` ? + // Because, middle mouse sometimes have this: + // e.button === 0 and e.buttons === 4 + // Furthermore, we want to prevent combination click, something like + // HOLD middlemouse then left click, that would be + // e.button === 0, e.buttons === 5 + // just `button` is not gonna work + // Alright, then what this block does ? + // this is for chrome `simulate mobile devices` + // I want to support this as well + return true; + } + + if (event.button === 0 && event.buttons === undefined) { + // Touch screen, sometimes on some specific device, `buttons` + // doesn't have anything (safari on ios, blackberry...) + return true; + } // `mouseup` event on a single left click has + // `button` and `buttons` equal to 0 + + + if (event.type === 'mouseup' && event.button === 0 && event.buttons === 0) { + return true; + } + + if (event.button !== 0 || event.buttons !== 1) { + // This is the reason we have those if else block above + // if any special case we can catch and let it slide + // we do it above, when get to here, this definitely + // is-not-left-click + return false; + } + + return true; + } + /** + * Finds a single DOM element matching `selector` within the optional + * `context` of another DOM element (defaulting to `document`). + * + * @param {string} selector + * A valid CSS selector, which will be passed to `querySelector`. + * + * @param {Element|String} [context=document] + * A DOM element within which to query. Can also be a selector + * string in which case the first matching element will be used + * as context. If missing (or no element matches selector), falls + * back to `document`. + * + * @return {Element|null} + * The element that was found or null. + */ + + var $ = createQuerier('querySelector'); + /** + * Finds a all DOM elements matching `selector` within the optional + * `context` of another DOM element (defaulting to `document`). + * + * @param {string} selector + * A valid CSS selector, which will be passed to `querySelectorAll`. + * + * @param {Element|String} [context=document] + * A DOM element within which to query. Can also be a selector + * string in which case the first matching element will be used + * as context. If missing (or no element matches selector), falls + * back to `document`. + * + * @return {NodeList} + * A element list of elements that were found. Will be empty if none + * were found. + * + */ + + var $$ = createQuerier('querySelectorAll'); + + var Dom = /*#__PURE__*/Object.freeze({ + isReal: isReal, + isEl: isEl, + isInFrame: isInFrame, + createEl: createEl, + textContent: textContent, + prependTo: prependTo, + hasClass: hasClass, + addClass: addClass, + removeClass: removeClass, + toggleClass: toggleClass, + setAttributes: setAttributes, + getAttributes: getAttributes, + getAttribute: getAttribute, + setAttribute: setAttribute, + removeAttribute: removeAttribute, + blockTextSelection: blockTextSelection, + unblockTextSelection: unblockTextSelection, + getBoundingClientRect: getBoundingClientRect, + findPosition: findPosition, + getPointerPosition: getPointerPosition, + isTextNode: isTextNode, + emptyEl: emptyEl, + normalizeContent: normalizeContent, + appendContent: appendContent, + insertContent: insertContent, + isSingleLeftClick: isSingleLeftClick, + $: $, + $$: $$ + }); + + /** + * @file setup.js - Functions for setting up a player without + * user interaction based on the data-setup `attribute` of the video tag. + * + * @module setup + */ + var _windowLoaded = false; + var videojs; + /** + * Set up any tags that have a data-setup `attribute` when the player is started. + */ + + var autoSetup = function autoSetup() { + // Protect against breakage in non-browser environments and check global autoSetup option. + if (!isReal() || videojs.options.autoSetup === false) { + return; + } + + var vids = Array.prototype.slice.call(document.getElementsByTagName('video')); + var audios = Array.prototype.slice.call(document.getElementsByTagName('audio')); + var divs = Array.prototype.slice.call(document.getElementsByTagName('video-js')); + var mediaEls = vids.concat(audios, divs); // Check if any media elements exist + + if (mediaEls && mediaEls.length > 0) { + for (var i = 0, e = mediaEls.length; i < e; i++) { + var mediaEl = mediaEls[i]; // Check if element exists, has getAttribute func. + + if (mediaEl && mediaEl.getAttribute) { + // Make sure this player hasn't already been set up. + if (mediaEl.player === undefined) { + var options = mediaEl.getAttribute('data-setup'); // Check if data-setup attr exists. + // We only auto-setup if they've added the data-setup attr. + + if (options !== null) { + // Create new video.js instance. + videojs(mediaEl); + } + } // If getAttribute isn't defined, we need to wait for the DOM. + + } else { + autoSetupTimeout(1); + break; + } + } // No videos were found, so keep looping unless page is finished loading. + + } else if (!_windowLoaded) { + autoSetupTimeout(1); + } + }; + /** + * Wait until the page is loaded before running autoSetup. This will be called in + * autoSetup if `hasLoaded` returns false. + * + * @param {number} wait + * How long to wait in ms + * + * @param {module:videojs} [vjs] + * The videojs library function + */ + + + function autoSetupTimeout(wait, vjs) { + if (vjs) { + videojs = vjs; + } + + window$1.setTimeout(autoSetup, wait); + } + /** + * Used to set the internal tracking of window loaded state to true. + * + * @private + */ + + + function setWindowLoaded() { + _windowLoaded = true; + window$1.removeEventListener('load', setWindowLoaded); + } + + if (isReal()) { + if (document.readyState === 'complete') { + setWindowLoaded(); + } else { + /** + * Listen for the load event on window, and set _windowLoaded to true. + * + * We use a standard event listener here to avoid incrementing the GUID + * before any players are created. + * + * @listens load + */ + window$1.addEventListener('load', setWindowLoaded); + } + } + + /** + * @file stylesheet.js + * @module stylesheet + */ + /** + * Create a DOM syle element given a className for it. + * + * @param {string} className + * The className to add to the created style element. + * + * @return {Element} + * The element that was created. + */ + + var createStyleElement = function createStyleElement(className) { + var style = document.createElement('style'); + style.className = className; + return style; + }; + /** + * Add text to a DOM element. + * + * @param {Element} el + * The Element to add text content to. + * + * @param {string} content + * The text to add to the element. + */ + + var setTextContent = function setTextContent(el, content) { + if (el.styleSheet) { + el.styleSheet.cssText = content; + } else { + el.textContent = content; + } + }; + + /** + * @file guid.js + * @module guid + */ + // Default value for GUIDs. This allows us to reset the GUID counter in tests. + // + // The initial GUID is 3 because some users have come to rely on the first + // default player ID ending up as `vjs_video_3`. + // + // See: https://github.com/videojs/video.js/pull/6216 + var _initialGuid = 3; + /** + * Unique ID for an element or function + * + * @type {Number} + */ + + var _guid = _initialGuid; + /** + * Get a unique auto-incrementing ID by number that has not been returned before. + * + * @return {number} + * A new unique ID. + */ + + function newGUID() { + return _guid++; + } + + /** + * @file dom-data.js + * @module dom-data + */ + var FakeWeakMap; + + if (!window$1.WeakMap) { + FakeWeakMap = + /*#__PURE__*/ + function () { + function FakeWeakMap() { + this.vdata = 'vdata' + Math.floor(window$1.performance && window$1.performance.now() || Date.now()); + this.data = {}; + } + + var _proto = FakeWeakMap.prototype; + + _proto.set = function set(key, value) { + var access = key[this.vdata] || newGUID(); + + if (!key[this.vdata]) { + key[this.vdata] = access; + } + + this.data[access] = value; + return this; + }; + + _proto.get = function get(key) { + var access = key[this.vdata]; // we have data, return it + + if (access) { + return this.data[access]; + } // we don't have data, return nothing. + // return undefined explicitly as that's the contract for this method + + + log('We have no data for this element', key); + return undefined; + }; + + _proto.has = function has(key) { + var access = key[this.vdata]; + return access in this.data; + }; + + _proto["delete"] = function _delete(key) { + var access = key[this.vdata]; + + if (access) { + delete this.data[access]; + delete key[this.vdata]; + } + }; + + return FakeWeakMap; + }(); + } + /** + * Element Data Store. + * + * Allows for binding data to an element without putting it directly on the + * element. Ex. Event listeners are stored here. + * (also from jsninja.com, slightly modified and updated for closure compiler) + * + * @type {Object} + * @private + */ + + + var DomData = window$1.WeakMap ? new WeakMap() : new FakeWeakMap(); + + /** + * @file events.js. An Event System (John Resig - Secrets of a JS Ninja http://jsninja.com/) + * (Original book version wasn't completely usable, so fixed some things and made Closure Compiler compatible) + * This should work very similarly to jQuery's events, however it's based off the book version which isn't as + * robust as jquery's, so there's probably some differences. + * + * @file events.js + * @module events + */ + /** + * Clean up the listener cache and dispatchers + * + * @param {Element|Object} elem + * Element to clean up + * + * @param {string} type + * Type of event to clean up + */ + + function _cleanUpEvents(elem, type) { + if (!DomData.has(elem)) { + return; + } + + var data = DomData.get(elem); // Remove the events of a particular type if there are none left + + if (data.handlers[type].length === 0) { + delete data.handlers[type]; // data.handlers[type] = null; + // Setting to null was causing an error with data.handlers + // Remove the meta-handler from the element + + if (elem.removeEventListener) { + elem.removeEventListener(type, data.dispatcher, false); + } else if (elem.detachEvent) { + elem.detachEvent('on' + type, data.dispatcher); + } + } // Remove the events object if there are no types left + + + if (Object.getOwnPropertyNames(data.handlers).length <= 0) { + delete data.handlers; + delete data.dispatcher; + delete data.disabled; + } // Finally remove the element data if there is no data left + + + if (Object.getOwnPropertyNames(data).length === 0) { + DomData["delete"](elem); + } + } + /** + * Loops through an array of event types and calls the requested method for each type. + * + * @param {Function} fn + * The event method we want to use. + * + * @param {Element|Object} elem + * Element or object to bind listeners to + * + * @param {string} type + * Type of event to bind to. + * + * @param {EventTarget~EventListener} callback + * Event listener. + */ + + + function _handleMultipleEvents(fn, elem, types, callback) { + types.forEach(function (type) { + // Call the event method for each one of the types + fn(elem, type, callback); + }); + } + /** + * Fix a native event to have standard property values + * + * @param {Object} event + * Event object to fix. + * + * @return {Object} + * Fixed event object. + */ + + + function fixEvent(event) { + if (event.fixed_) { + return event; + } + + function returnTrue() { + return true; + } + + function returnFalse() { + return false; + } // Test if fixing up is needed + // Used to check if !event.stopPropagation instead of isPropagationStopped + // But native events return true for stopPropagation, but don't have + // other expected methods like isPropagationStopped. Seems to be a problem + // with the Javascript Ninja code. So we're just overriding all events now. + + + if (!event || !event.isPropagationStopped) { + var old = event || window$1.event; + event = {}; // Clone the old object so that we can modify the values event = {}; + // IE8 Doesn't like when you mess with native event properties + // Firefox returns false for event.hasOwnProperty('type') and other props + // which makes copying more difficult. + // TODO: Probably best to create a whitelist of event props + + for (var key in old) { + // Safari 6.0.3 warns you if you try to copy deprecated layerX/Y + // Chrome warns you if you try to copy deprecated keyboardEvent.keyLocation + // and webkitMovementX/Y + if (key !== 'layerX' && key !== 'layerY' && key !== 'keyLocation' && key !== 'webkitMovementX' && key !== 'webkitMovementY') { + // Chrome 32+ warns if you try to copy deprecated returnValue, but + // we still want to if preventDefault isn't supported (IE8). + if (!(key === 'returnValue' && old.preventDefault)) { + event[key] = old[key]; + } + } + } // The event occurred on this element + + + if (!event.target) { + event.target = event.srcElement || document; + } // Handle which other element the event is related to + + + if (!event.relatedTarget) { + event.relatedTarget = event.fromElement === event.target ? event.toElement : event.fromElement; + } // Stop the default browser action + + + event.preventDefault = function () { + if (old.preventDefault) { + old.preventDefault(); + } + + event.returnValue = false; + old.returnValue = false; + event.defaultPrevented = true; + }; + + event.defaultPrevented = false; // Stop the event from bubbling + + event.stopPropagation = function () { + if (old.stopPropagation) { + old.stopPropagation(); + } + + event.cancelBubble = true; + old.cancelBubble = true; + event.isPropagationStopped = returnTrue; + }; + + event.isPropagationStopped = returnFalse; // Stop the event from bubbling and executing other handlers + + event.stopImmediatePropagation = function () { + if (old.stopImmediatePropagation) { + old.stopImmediatePropagation(); + } + + event.isImmediatePropagationStopped = returnTrue; + event.stopPropagation(); + }; + + event.isImmediatePropagationStopped = returnFalse; // Handle mouse position + + if (event.clientX !== null && event.clientX !== undefined) { + var doc = document.documentElement; + var body = document.body; + event.pageX = event.clientX + (doc && doc.scrollLeft || body && body.scrollLeft || 0) - (doc && doc.clientLeft || body && body.clientLeft || 0); + event.pageY = event.clientY + (doc && doc.scrollTop || body && body.scrollTop || 0) - (doc && doc.clientTop || body && body.clientTop || 0); + } // Handle key presses + + + event.which = event.charCode || event.keyCode; // Fix button for mouse clicks: + // 0 == left; 1 == middle; 2 == right + + if (event.button !== null && event.button !== undefined) { + // The following is disabled because it does not pass videojs-standard + // and... yikes. + + /* eslint-disable */ + event.button = event.button & 1 ? 0 : event.button & 4 ? 1 : event.button & 2 ? 2 : 0; + /* eslint-enable */ + } + } + + event.fixed_ = true; // Returns fixed-up instance + + return event; + } + /** + * Whether passive event listeners are supported + */ + + var _supportsPassive; + + var supportsPassive = function supportsPassive() { + if (typeof _supportsPassive !== 'boolean') { + _supportsPassive = false; + + try { + var opts = Object.defineProperty({}, 'passive', { + get: function get() { + _supportsPassive = true; + } + }); + window$1.addEventListener('test', null, opts); + window$1.removeEventListener('test', null, opts); + } catch (e) {// disregard + } + } + + return _supportsPassive; + }; + /** + * Touch events Chrome expects to be passive + */ + + + var passiveEvents = ['touchstart', 'touchmove']; + /** + * Add an event listener to element + * It stores the handler function in a separate cache object + * and adds a generic handler to the element's event, + * along with a unique id (guid) to the element. + * + * @param {Element|Object} elem + * Element or object to bind listeners to + * + * @param {string|string[]} type + * Type of event to bind to. + * + * @param {EventTarget~EventListener} fn + * Event listener. + */ + + function on(elem, type, fn) { + if (Array.isArray(type)) { + return _handleMultipleEvents(on, elem, type, fn); + } + + if (!DomData.has(elem)) { + DomData.set(elem, {}); + } + + var data = DomData.get(elem); // We need a place to store all our handler data + + if (!data.handlers) { + data.handlers = {}; + } + + if (!data.handlers[type]) { + data.handlers[type] = []; + } + + if (!fn.guid) { + fn.guid = newGUID(); + } + + data.handlers[type].push(fn); + + if (!data.dispatcher) { + data.disabled = false; + + data.dispatcher = function (event, hash) { + if (data.disabled) { + return; + } + + event = fixEvent(event); + var handlers = data.handlers[event.type]; + + if (handlers) { + // Copy handlers so if handlers are added/removed during the process it doesn't throw everything off. + var handlersCopy = handlers.slice(0); + + for (var m = 0, n = handlersCopy.length; m < n; m++) { + if (event.isImmediatePropagationStopped()) { + break; + } else { + try { + handlersCopy[m].call(elem, event, hash); + } catch (e) { + log.error(e); + } + } + } + } + }; + } + + if (data.handlers[type].length === 1) { + if (elem.addEventListener) { + var options = false; + + if (supportsPassive() && passiveEvents.indexOf(type) > -1) { + options = { + passive: true + }; + } + + elem.addEventListener(type, data.dispatcher, options); + } else if (elem.attachEvent) { + elem.attachEvent('on' + type, data.dispatcher); + } + } + } + /** + * Removes event listeners from an element + * + * @param {Element|Object} elem + * Object to remove listeners from. + * + * @param {string|string[]} [type] + * Type of listener to remove. Don't include to remove all events from element. + * + * @param {EventTarget~EventListener} [fn] + * Specific listener to remove. Don't include to remove listeners for an event + * type. + */ + + function off(elem, type, fn) { + // Don't want to add a cache object through getElData if not needed + if (!DomData.has(elem)) { + return; + } + + var data = DomData.get(elem); // If no events exist, nothing to unbind + + if (!data.handlers) { + return; + } + + if (Array.isArray(type)) { + return _handleMultipleEvents(off, elem, type, fn); + } // Utility function + + + var removeType = function removeType(el, t) { + data.handlers[t] = []; + + _cleanUpEvents(el, t); + }; // Are we removing all bound events? + + + if (type === undefined) { + for (var t in data.handlers) { + if (Object.prototype.hasOwnProperty.call(data.handlers || {}, t)) { + removeType(elem, t); + } + } + + return; + } + + var handlers = data.handlers[type]; // If no handlers exist, nothing to unbind + + if (!handlers) { + return; + } // If no listener was provided, remove all listeners for type + + + if (!fn) { + removeType(elem, type); + return; + } // We're only removing a single handler + + + if (fn.guid) { + for (var n = 0; n < handlers.length; n++) { + if (handlers[n].guid === fn.guid) { + handlers.splice(n--, 1); + } + } + } + + _cleanUpEvents(elem, type); + } + /** + * Trigger an event for an element + * + * @param {Element|Object} elem + * Element to trigger an event on + * + * @param {EventTarget~Event|string} event + * A string (the type) or an event object with a type attribute + * + * @param {Object} [hash] + * data hash to pass along with the event + * + * @return {boolean|undefined} + * Returns the opposite of `defaultPrevented` if default was + * prevented. Otherwise, returns `undefined` + */ + + function trigger(elem, event, hash) { + // Fetches element data and a reference to the parent (for bubbling). + // Don't want to add a data object to cache for every parent, + // so checking hasElData first. + var elemData = DomData.has(elem) ? DomData.get(elem) : {}; + var parent = elem.parentNode || elem.ownerDocument; // type = event.type || event, + // handler; + // If an event name was passed as a string, creates an event out of it + + if (typeof event === 'string') { + event = { + type: event, + target: elem + }; + } else if (!event.target) { + event.target = elem; + } // Normalizes the event properties. + + + event = fixEvent(event); // If the passed element has a dispatcher, executes the established handlers. + + if (elemData.dispatcher) { + elemData.dispatcher.call(elem, event, hash); + } // Unless explicitly stopped or the event does not bubble (e.g. media events) + // recursively calls this function to bubble the event up the DOM. + + + if (parent && !event.isPropagationStopped() && event.bubbles === true) { + trigger.call(null, parent, event, hash); // If at the top of the DOM, triggers the default action unless disabled. + } else if (!parent && !event.defaultPrevented && event.target && event.target[event.type]) { + if (!DomData.has(event.target)) { + DomData.set(event.target, {}); + } + + var targetData = DomData.get(event.target); // Checks if the target has a default action for this event. + + if (event.target[event.type]) { + // Temporarily disables event dispatching on the target as we have already executed the handler. + targetData.disabled = true; // Executes the default action. + + if (typeof event.target[event.type] === 'function') { + event.target[event.type](); + } // Re-enables event dispatching. + + + targetData.disabled = false; + } + } // Inform the triggerer if the default was prevented by returning false + + + return !event.defaultPrevented; + } + /** + * Trigger a listener only once for an event. + * + * @param {Element|Object} elem + * Element or object to bind to. + * + * @param {string|string[]} type + * Name/type of event + * + * @param {Event~EventListener} fn + * Event listener function + */ + + function one(elem, type, fn) { + if (Array.isArray(type)) { + return _handleMultipleEvents(one, elem, type, fn); + } + + var func = function func() { + off(elem, type, func); + fn.apply(this, arguments); + }; // copy the guid to the new function so it can removed using the original function's ID + + + func.guid = fn.guid = fn.guid || newGUID(); + on(elem, type, func); + } + /** + * Trigger a listener only once and then turn if off for all + * configured events + * + * @param {Element|Object} elem + * Element or object to bind to. + * + * @param {string|string[]} type + * Name/type of event + * + * @param {Event~EventListener} fn + * Event listener function + */ + + function any(elem, type, fn) { + var func = function func() { + off(elem, type, func); + fn.apply(this, arguments); + }; // copy the guid to the new function so it can removed using the original function's ID + + + func.guid = fn.guid = fn.guid || newGUID(); // multiple ons, but one off for everything + + on(elem, type, func); + } + + var Events = /*#__PURE__*/Object.freeze({ + fixEvent: fixEvent, + on: on, + off: off, + trigger: trigger, + one: one, + any: any + }); + + /** + * @file fn.js + * @module fn + */ + var UPDATE_REFRESH_INTERVAL = 30; + /** + * Bind (a.k.a proxy or context). A simple method for changing the context of + * a function. + * + * It also stores a unique id on the function so it can be easily removed from + * events. + * + * @function + * @param {Mixed} context + * The object to bind as scope. + * + * @param {Function} fn + * The function to be bound to a scope. + * + * @param {number} [uid] + * An optional unique ID for the function to be set + * + * @return {Function} + * The new function that will be bound into the context given + */ + + var bind = function bind(context, fn, uid) { + // Make sure the function has a unique ID + if (!fn.guid) { + fn.guid = newGUID(); + } // Create the new function that changes the context + + + var bound = fn.bind(context); // Allow for the ability to individualize this function + // Needed in the case where multiple objects might share the same prototype + // IF both items add an event listener with the same function, then you try to remove just one + // it will remove both because they both have the same guid. + // when using this, you need to use the bind method when you remove the listener as well. + // currently used in text tracks + + bound.guid = uid ? uid + '_' + fn.guid : fn.guid; + return bound; + }; + /** + * Wraps the given function, `fn`, with a new function that only invokes `fn` + * at most once per every `wait` milliseconds. + * + * @function + * @param {Function} fn + * The function to be throttled. + * + * @param {number} wait + * The number of milliseconds by which to throttle. + * + * @return {Function} + */ + + var throttle = function throttle(fn, wait) { + var last = window$1.performance.now(); + + var throttled = function throttled() { + var now = window$1.performance.now(); + + if (now - last >= wait) { + fn.apply(void 0, arguments); + last = now; + } + }; + + return throttled; + }; + /** + * Creates a debounced function that delays invoking `func` until after `wait` + * milliseconds have elapsed since the last time the debounced function was + * invoked. + * + * Inspired by lodash and underscore implementations. + * + * @function + * @param {Function} func + * The function to wrap with debounce behavior. + * + * @param {number} wait + * The number of milliseconds to wait after the last invocation. + * + * @param {boolean} [immediate] + * Whether or not to invoke the function immediately upon creation. + * + * @param {Object} [context=window] + * The "context" in which the debounced function should debounce. For + * example, if this function should be tied to a Video.js player, + * the player can be passed here. Alternatively, defaults to the + * global `window` object. + * + * @return {Function} + * A debounced function. + */ + + var debounce = function debounce(func, wait, immediate, context) { + if (context === void 0) { + context = window$1; + } + + var timeout; + + var cancel = function cancel() { + context.clearTimeout(timeout); + timeout = null; + }; + /* eslint-disable consistent-this */ + + + var debounced = function debounced() { + var self = this; + var args = arguments; + + var _later = function later() { + timeout = null; + _later = null; + + if (!immediate) { + func.apply(self, args); + } + }; + + if (!timeout && immediate) { + func.apply(self, args); + } + + context.clearTimeout(timeout); + timeout = context.setTimeout(_later, wait); + }; + /* eslint-enable consistent-this */ + + + debounced.cancel = cancel; + return debounced; + }; + + /** + * @file src/js/event-target.js + */ + /** + * `EventTarget` is a class that can have the same API as the DOM `EventTarget`. It + * adds shorthand functions that wrap around lengthy functions. For example: + * the `on` function is a wrapper around `addEventListener`. + * + * @see [EventTarget Spec]{@link https://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-EventTarget} + * @class EventTarget + */ + + var EventTarget = function EventTarget() {}; + /** + * A Custom DOM event. + * + * @typedef {Object} EventTarget~Event + * @see [Properties]{@link https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent} + */ + + /** + * All event listeners should follow the following format. + * + * @callback EventTarget~EventListener + * @this {EventTarget} + * + * @param {EventTarget~Event} event + * the event that triggered this function + * + * @param {Object} [hash] + * hash of data sent during the event + */ + + /** + * An object containing event names as keys and booleans as values. + * + * > NOTE: If an event name is set to a true value here {@link EventTarget#trigger} + * will have extra functionality. See that function for more information. + * + * @property EventTarget.prototype.allowedEvents_ + * @private + */ + + + EventTarget.prototype.allowedEvents_ = {}; + /** + * Adds an `event listener` to an instance of an `EventTarget`. An `event listener` is a + * function that will get called when an event with a certain name gets triggered. + * + * @param {string|string[]} type + * An event name or an array of event names. + * + * @param {EventTarget~EventListener} fn + * The function to call with `EventTarget`s + */ + + EventTarget.prototype.on = function (type, fn) { + // Remove the addEventListener alias before calling Events.on + // so we don't get into an infinite type loop + var ael = this.addEventListener; + + this.addEventListener = function () {}; + + on(this, type, fn); + this.addEventListener = ael; + }; + /** + * An alias of {@link EventTarget#on}. Allows `EventTarget` to mimic + * the standard DOM API. + * + * @function + * @see {@link EventTarget#on} + */ + + + EventTarget.prototype.addEventListener = EventTarget.prototype.on; + /** + * Removes an `event listener` for a specific event from an instance of `EventTarget`. + * This makes it so that the `event listener` will no longer get called when the + * named event happens. + * + * @param {string|string[]} type + * An event name or an array of event names. + * + * @param {EventTarget~EventListener} fn + * The function to remove. + */ + + EventTarget.prototype.off = function (type, fn) { + off(this, type, fn); + }; + /** + * An alias of {@link EventTarget#off}. Allows `EventTarget` to mimic + * the standard DOM API. + * + * @function + * @see {@link EventTarget#off} + */ + + + EventTarget.prototype.removeEventListener = EventTarget.prototype.off; + /** + * This function will add an `event listener` that gets triggered only once. After the + * first trigger it will get removed. This is like adding an `event listener` + * with {@link EventTarget#on} that calls {@link EventTarget#off} on itself. + * + * @param {string|string[]} type + * An event name or an array of event names. + * + * @param {EventTarget~EventListener} fn + * The function to be called once for each event name. + */ + + EventTarget.prototype.one = function (type, fn) { + // Remove the addEventListener aliasing Events.on + // so we don't get into an infinite type loop + var ael = this.addEventListener; + + this.addEventListener = function () {}; + + one(this, type, fn); + this.addEventListener = ael; + }; + + EventTarget.prototype.any = function (type, fn) { + // Remove the addEventListener aliasing Events.on + // so we don't get into an infinite type loop + var ael = this.addEventListener; + + this.addEventListener = function () {}; + + any(this, type, fn); + this.addEventListener = ael; + }; + /** + * This function causes an event to happen. This will then cause any `event listeners` + * that are waiting for that event, to get called. If there are no `event listeners` + * for an event then nothing will happen. + * + * If the name of the `Event` that is being triggered is in `EventTarget.allowedEvents_`. + * Trigger will also call the `on` + `uppercaseEventName` function. + * + * Example: + * 'click' is in `EventTarget.allowedEvents_`, so, trigger will attempt to call + * `onClick` if it exists. + * + * @param {string|EventTarget~Event|Object} event + * The name of the event, an `Event`, or an object with a key of type set to + * an event name. + */ + + + EventTarget.prototype.trigger = function (event) { + var type = event.type || event; // deprecation + // In a future version we should default target to `this` + // similar to how we default the target to `elem` in + // `Events.trigger`. Right now the default `target` will be + // `document` due to the `Event.fixEvent` call. + + if (typeof event === 'string') { + event = { + type: type + }; + } + + event = fixEvent(event); + + if (this.allowedEvents_[type] && this['on' + type]) { + this['on' + type](event); + } + + trigger(this, event); + }; + /** + * An alias of {@link EventTarget#trigger}. Allows `EventTarget` to mimic + * the standard DOM API. + * + * @function + * @see {@link EventTarget#trigger} + */ + + + EventTarget.prototype.dispatchEvent = EventTarget.prototype.trigger; + var EVENT_MAP; + + EventTarget.prototype.queueTrigger = function (event) { + var _this = this; + + // only set up EVENT_MAP if it'll be used + if (!EVENT_MAP) { + EVENT_MAP = new Map(); + } + + var type = event.type || event; + var map = EVENT_MAP.get(this); + + if (!map) { + map = new Map(); + EVENT_MAP.set(this, map); + } + + var oldTimeout = map.get(type); + map["delete"](type); + window$1.clearTimeout(oldTimeout); + var timeout = window$1.setTimeout(function () { + // if we cleared out all timeouts for the current target, delete its map + if (map.size === 0) { + map = null; + EVENT_MAP["delete"](_this); + } + + _this.trigger(event); + }, 0); + map.set(type, timeout); + }; + + /** + * @file mixins/evented.js + * @module evented + */ + /** + * Returns whether or not an object has had the evented mixin applied. + * + * @param {Object} object + * An object to test. + * + * @return {boolean} + * Whether or not the object appears to be evented. + */ + + var isEvented = function isEvented(object) { + return object instanceof EventTarget || !!object.eventBusEl_ && ['on', 'one', 'off', 'trigger'].every(function (k) { + return typeof object[k] === 'function'; + }); + }; + /** + * Adds a callback to run after the evented mixin applied. + * + * @param {Object} object + * An object to Add + * @param {Function} callback + * The callback to run. + */ + + + var addEventedCallback = function addEventedCallback(target, callback) { + if (isEvented(target)) { + callback(); + } else { + if (!target.eventedCallbacks) { + target.eventedCallbacks = []; + } + + target.eventedCallbacks.push(callback); + } + }; + /** + * Whether a value is a valid event type - non-empty string or array. + * + * @private + * @param {string|Array} type + * The type value to test. + * + * @return {boolean} + * Whether or not the type is a valid event type. + */ + + + var isValidEventType = function isValidEventType(type) { + return (// The regex here verifies that the `type` contains at least one non- + // whitespace character. + typeof type === 'string' && /\S/.test(type) || Array.isArray(type) && !!type.length + ); + }; + /** + * Validates a value to determine if it is a valid event target. Throws if not. + * + * @private + * @throws {Error} + * If the target does not appear to be a valid event target. + * + * @param {Object} target + * The object to test. + */ + + + var validateTarget = function validateTarget(target) { + if (!target.nodeName && !isEvented(target)) { + throw new Error('Invalid target; must be a DOM node or evented object.'); + } + }; + /** + * Validates a value to determine if it is a valid event target. Throws if not. + * + * @private + * @throws {Error} + * If the type does not appear to be a valid event type. + * + * @param {string|Array} type + * The type to test. + */ + + + var validateEventType = function validateEventType(type) { + if (!isValidEventType(type)) { + throw new Error('Invalid event type; must be a non-empty string or array.'); + } + }; + /** + * Validates a value to determine if it is a valid listener. Throws if not. + * + * @private + * @throws {Error} + * If the listener is not a function. + * + * @param {Function} listener + * The listener to test. + */ + + + var validateListener = function validateListener(listener) { + if (typeof listener !== 'function') { + throw new Error('Invalid listener; must be a function.'); + } + }; + /** + * Takes an array of arguments given to `on()` or `one()`, validates them, and + * normalizes them into an object. + * + * @private + * @param {Object} self + * The evented object on which `on()` or `one()` was called. This + * object will be bound as the `this` value for the listener. + * + * @param {Array} args + * An array of arguments passed to `on()` or `one()`. + * + * @return {Object} + * An object containing useful values for `on()` or `one()` calls. + */ + + + var normalizeListenArgs = function normalizeListenArgs(self, args) { + // If the number of arguments is less than 3, the target is always the + // evented object itself. + var isTargetingSelf = args.length < 3 || args[0] === self || args[0] === self.eventBusEl_; + var target; + var type; + var listener; + + if (isTargetingSelf) { + target = self.eventBusEl_; // Deal with cases where we got 3 arguments, but we are still listening to + // the evented object itself. + + if (args.length >= 3) { + args.shift(); + } + + type = args[0]; + listener = args[1]; + } else { + target = args[0]; + type = args[1]; + listener = args[2]; + } + + validateTarget(target); + validateEventType(type); + validateListener(listener); + listener = bind(self, listener); + return { + isTargetingSelf: isTargetingSelf, + target: target, + type: type, + listener: listener + }; + }; + /** + * Adds the listener to the event type(s) on the target, normalizing for + * the type of target. + * + * @private + * @param {Element|Object} target + * A DOM node or evented object. + * + * @param {string} method + * The event binding method to use ("on" or "one"). + * + * @param {string|Array} type + * One or more event type(s). + * + * @param {Function} listener + * A listener function. + */ + + + var listen = function listen(target, method, type, listener) { + validateTarget(target); + + if (target.nodeName) { + Events[method](target, type, listener); + } else { + target[method](type, listener); + } + }; + /** + * Contains methods that provide event capabilities to an object which is passed + * to {@link module:evented|evented}. + * + * @mixin EventedMixin + */ + + + var EventedMixin = { + /** + * Add a listener to an event (or events) on this object or another evented + * object. + * + * @param {string|Array|Element|Object} targetOrType + * If this is a string or array, it represents the event type(s) + * that will trigger the listener. + * + * Another evented object can be passed here instead, which will + * cause the listener to listen for events on _that_ object. + * + * In either case, the listener's `this` value will be bound to + * this object. + * + * @param {string|Array|Function} typeOrListener + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [listener] + * If the first argument was another evented object, this will be + * the listener function. + */ + on: function on() { + var _this = this; + + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + var _normalizeListenArgs = normalizeListenArgs(this, args), + isTargetingSelf = _normalizeListenArgs.isTargetingSelf, + target = _normalizeListenArgs.target, + type = _normalizeListenArgs.type, + listener = _normalizeListenArgs.listener; + + listen(target, 'on', type, listener); // If this object is listening to another evented object. + + if (!isTargetingSelf) { + // If this object is disposed, remove the listener. + var removeListenerOnDispose = function removeListenerOnDispose() { + return _this.off(target, type, listener); + }; // Use the same function ID as the listener so we can remove it later it + // using the ID of the original listener. + + + removeListenerOnDispose.guid = listener.guid; // Add a listener to the target's dispose event as well. This ensures + // that if the target is disposed BEFORE this object, we remove the + // removal listener that was just added. Otherwise, we create a memory leak. + + var removeRemoverOnTargetDispose = function removeRemoverOnTargetDispose() { + return _this.off('dispose', removeListenerOnDispose); + }; // Use the same function ID as the listener so we can remove it later + // it using the ID of the original listener. + + + removeRemoverOnTargetDispose.guid = listener.guid; + listen(this, 'on', 'dispose', removeListenerOnDispose); + listen(target, 'on', 'dispose', removeRemoverOnTargetDispose); + } + }, + + /** + * Add a listener to an event (or events) on this object or another evented + * object. The listener will be called once per event and then removed. + * + * @param {string|Array|Element|Object} targetOrType + * If this is a string or array, it represents the event type(s) + * that will trigger the listener. + * + * Another evented object can be passed here instead, which will + * cause the listener to listen for events on _that_ object. + * + * In either case, the listener's `this` value will be bound to + * this object. + * + * @param {string|Array|Function} typeOrListener + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [listener] + * If the first argument was another evented object, this will be + * the listener function. + */ + one: function one() { + var _this2 = this; + + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + var _normalizeListenArgs2 = normalizeListenArgs(this, args), + isTargetingSelf = _normalizeListenArgs2.isTargetingSelf, + target = _normalizeListenArgs2.target, + type = _normalizeListenArgs2.type, + listener = _normalizeListenArgs2.listener; // Targeting this evented object. + + + if (isTargetingSelf) { + listen(target, 'one', type, listener); // Targeting another evented object. + } else { + // TODO: This wrapper is incorrect! It should only + // remove the wrapper for the event type that called it. + // Instead all listners are removed on the first trigger! + // see https://github.com/videojs/video.js/issues/5962 + var wrapper = function wrapper() { + _this2.off(target, type, wrapper); + + for (var _len3 = arguments.length, largs = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { + largs[_key3] = arguments[_key3]; + } + + listener.apply(null, largs); + }; // Use the same function ID as the listener so we can remove it later + // it using the ID of the original listener. + + + wrapper.guid = listener.guid; + listen(target, 'one', type, wrapper); + } + }, + + /** + * Add a listener to an event (or events) on this object or another evented + * object. The listener will only be called once for the first event that is triggered + * then removed. + * + * @param {string|Array|Element|Object} targetOrType + * If this is a string or array, it represents the event type(s) + * that will trigger the listener. + * + * Another evented object can be passed here instead, which will + * cause the listener to listen for events on _that_ object. + * + * In either case, the listener's `this` value will be bound to + * this object. + * + * @param {string|Array|Function} typeOrListener + * If the first argument was a string or array, this should be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [listener] + * If the first argument was another evented object, this will be + * the listener function. + */ + any: function any() { + var _this3 = this; + + for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + + var _normalizeListenArgs3 = normalizeListenArgs(this, args), + isTargetingSelf = _normalizeListenArgs3.isTargetingSelf, + target = _normalizeListenArgs3.target, + type = _normalizeListenArgs3.type, + listener = _normalizeListenArgs3.listener; // Targeting this evented object. + + + if (isTargetingSelf) { + listen(target, 'any', type, listener); // Targeting another evented object. + } else { + var wrapper = function wrapper() { + _this3.off(target, type, wrapper); + + for (var _len5 = arguments.length, largs = new Array(_len5), _key5 = 0; _key5 < _len5; _key5++) { + largs[_key5] = arguments[_key5]; + } + + listener.apply(null, largs); + }; // Use the same function ID as the listener so we can remove it later + // it using the ID of the original listener. + + + wrapper.guid = listener.guid; + listen(target, 'any', type, wrapper); + } + }, + + /** + * Removes listener(s) from event(s) on an evented object. + * + * @param {string|Array|Element|Object} [targetOrType] + * If this is a string or array, it represents the event type(s). + * + * Another evented object can be passed here instead, in which case + * ALL 3 arguments are _required_. + * + * @param {string|Array|Function} [typeOrListener] + * If the first argument was a string or array, this may be the + * listener function. Otherwise, this is a string or array of event + * type(s). + * + * @param {Function} [listener] + * If the first argument was another evented object, this will be + * the listener function; otherwise, _all_ listeners bound to the + * event type(s) will be removed. + */ + off: function off$1(targetOrType, typeOrListener, listener) { + // Targeting this evented object. + if (!targetOrType || isValidEventType(targetOrType)) { + off(this.eventBusEl_, targetOrType, typeOrListener); // Targeting another evented object. + } else { + var target = targetOrType; + var type = typeOrListener; // Fail fast and in a meaningful way! + + validateTarget(target); + validateEventType(type); + validateListener(listener); // Ensure there's at least a guid, even if the function hasn't been used + + listener = bind(this, listener); // Remove the dispose listener on this evented object, which was given + // the same guid as the event listener in on(). + + this.off('dispose', listener); + + if (target.nodeName) { + off(target, type, listener); + off(target, 'dispose', listener); + } else if (isEvented(target)) { + target.off(type, listener); + target.off('dispose', listener); + } + } + }, + + /** + * Fire an event on this evented object, causing its listeners to be called. + * + * @param {string|Object} event + * An event type or an object with a type property. + * + * @param {Object} [hash] + * An additional object to pass along to listeners. + * + * @return {boolean} + * Whether or not the default behavior was prevented. + */ + trigger: function trigger$1(event, hash) { + return trigger(this.eventBusEl_, event, hash); + } + }; + /** + * Applies {@link module:evented~EventedMixin|EventedMixin} to a target object. + * + * @param {Object} target + * The object to which to add event methods. + * + * @param {Object} [options={}] + * Options for customizing the mixin behavior. + * + * @param {string} [options.eventBusKey] + * By default, adds a `eventBusEl_` DOM element to the target object, + * which is used as an event bus. If the target object already has a + * DOM element that should be used, pass its key here. + * + * @return {Object} + * The target object. + */ + + function evented(target, options) { + if (options === void 0) { + options = {}; + } + + var _options = options, + eventBusKey = _options.eventBusKey; // Set or create the eventBusEl_. + + if (eventBusKey) { + if (!target[eventBusKey].nodeName) { + throw new Error("The eventBusKey \"" + eventBusKey + "\" does not refer to an element."); + } + + target.eventBusEl_ = target[eventBusKey]; + } else { + target.eventBusEl_ = createEl('span', { + className: 'vjs-event-bus' + }); + } + + assign(target, EventedMixin); + + if (target.eventedCallbacks) { + target.eventedCallbacks.forEach(function (callback) { + callback(); + }); + } // When any evented object is disposed, it removes all its listeners. + + + target.on('dispose', function () { + target.off(); + window$1.setTimeout(function () { + target.eventBusEl_ = null; + }, 0); + }); + return target; + } + + /** + * @file mixins/stateful.js + * @module stateful + */ + /** + * Contains methods that provide statefulness to an object which is passed + * to {@link module:stateful}. + * + * @mixin StatefulMixin + */ + + var StatefulMixin = { + /** + * A hash containing arbitrary keys and values representing the state of + * the object. + * + * @type {Object} + */ + state: {}, + + /** + * Set the state of an object by mutating its + * {@link module:stateful~StatefulMixin.state|state} object in place. + * + * @fires module:stateful~StatefulMixin#statechanged + * @param {Object|Function} stateUpdates + * A new set of properties to shallow-merge into the plugin state. + * Can be a plain object or a function returning a plain object. + * + * @return {Object|undefined} + * An object containing changes that occurred. If no changes + * occurred, returns `undefined`. + */ + setState: function setState(stateUpdates) { + var _this = this; + + // Support providing the `stateUpdates` state as a function. + if (typeof stateUpdates === 'function') { + stateUpdates = stateUpdates(); + } + + var changes; + each(stateUpdates, function (value, key) { + // Record the change if the value is different from what's in the + // current state. + if (_this.state[key] !== value) { + changes = changes || {}; + changes[key] = { + from: _this.state[key], + to: value + }; + } + + _this.state[key] = value; + }); // Only trigger "statechange" if there were changes AND we have a trigger + // function. This allows us to not require that the target object be an + // evented object. + + if (changes && isEvented(this)) { + /** + * An event triggered on an object that is both + * {@link module:stateful|stateful} and {@link module:evented|evented} + * indicating that its state has changed. + * + * @event module:stateful~StatefulMixin#statechanged + * @type {Object} + * @property {Object} changes + * A hash containing the properties that were changed and + * the values they were changed `from` and `to`. + */ + this.trigger({ + changes: changes, + type: 'statechanged' + }); + } + + return changes; + } + }; + /** + * Applies {@link module:stateful~StatefulMixin|StatefulMixin} to a target + * object. + * + * If the target object is {@link module:evented|evented} and has a + * `handleStateChanged` method, that method will be automatically bound to the + * `statechanged` event on itself. + * + * @param {Object} target + * The object to be made stateful. + * + * @param {Object} [defaultState] + * A default set of properties to populate the newly-stateful object's + * `state` property. + * + * @return {Object} + * Returns the `target`. + */ + + function stateful(target, defaultState) { + assign(target, StatefulMixin); // This happens after the mixing-in because we need to replace the `state` + // added in that step. + + target.state = assign({}, target.state, defaultState); // Auto-bind the `handleStateChanged` method of the target object if it exists. + + if (typeof target.handleStateChanged === 'function' && isEvented(target)) { + target.on('statechanged', target.handleStateChanged); + } + + return target; + } + + /** + * @file string-cases.js + * @module to-lower-case + */ + + /** + * Lowercase the first letter of a string. + * + * @param {string} string + * String to be lowercased + * + * @return {string} + * The string with a lowercased first letter + */ + var toLowerCase = function toLowerCase(string) { + if (typeof string !== 'string') { + return string; + } + + return string.replace(/./, function (w) { + return w.toLowerCase(); + }); + }; + /** + * Uppercase the first letter of a string. + * + * @param {string} string + * String to be uppercased + * + * @return {string} + * The string with an uppercased first letter + */ + + var toTitleCase = function toTitleCase(string) { + if (typeof string !== 'string') { + return string; + } + + return string.replace(/./, function (w) { + return w.toUpperCase(); + }); + }; + /** + * Compares the TitleCase versions of the two strings for equality. + * + * @param {string} str1 + * The first string to compare + * + * @param {string} str2 + * The second string to compare + * + * @return {boolean} + * Whether the TitleCase versions of the strings are equal + */ + + var titleCaseEquals = function titleCaseEquals(str1, str2) { + return toTitleCase(str1) === toTitleCase(str2); + }; + + /** + * @file merge-options.js + * @module merge-options + */ + /** + * Merge two objects recursively. + * + * Performs a deep merge like + * {@link https://lodash.com/docs/4.17.10#merge|lodash.merge}, but only merges + * plain objects (not arrays, elements, or anything else). + * + * Non-plain object values will be copied directly from the right-most + * argument. + * + * @static + * @param {Object[]} sources + * One or more objects to merge into a new object. + * + * @return {Object} + * A new object that is the merged result of all sources. + */ + + function mergeOptions() { + var result = {}; + + for (var _len = arguments.length, sources = new Array(_len), _key = 0; _key < _len; _key++) { + sources[_key] = arguments[_key]; + } + + sources.forEach(function (source) { + if (!source) { + return; + } + + each(source, function (value, key) { + if (!isPlain(value)) { + result[key] = value; + return; + } + + if (!isPlain(result[key])) { + result[key] = {}; + } + + result[key] = mergeOptions(result[key], value); + }); + }); + return result; + } + + /** + * Player Component - Base class for all UI objects + * + * @file component.js + */ + /** + * Base class for all UI Components. + * Components are UI objects which represent both a javascript object and an element + * in the DOM. They can be children of other components, and can have + * children themselves. + * + * Components can also use methods from {@link EventTarget} + */ + + var Component = + /*#__PURE__*/ + function () { + /** + * A callback that is called when a component is ready. Does not have any + * paramters and any callback value will be ignored. + * + * @callback Component~ReadyCallback + * @this Component + */ + + /** + * Creates an instance of this class. + * + * @param {Player} player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param {Object[]} [options.children] + * An array of children objects to intialize this component with. Children objects have + * a name property that will be used if more than one component of the same type needs to be + * added. + * + * @param {Component~ReadyCallback} [ready] + * Function that gets called when the `Component` is ready. + */ + function Component(player, options, ready) { + // The component might be the player itself and we can't pass `this` to super + if (!player && this.play) { + this.player_ = player = this; // eslint-disable-line + } else { + this.player_ = player; + } + + this.isDisposed_ = false; // Hold the reference to the parent component via `addChild` method + + this.parentComponent_ = null; // Make a copy of prototype.options_ to protect against overriding defaults + + this.options_ = mergeOptions({}, this.options_); // Updated options with supplied options + + options = this.options_ = mergeOptions(this.options_, options); // Get ID from options or options element if one is supplied + + this.id_ = options.id || options.el && options.el.id; // If there was no ID from the options, generate one + + if (!this.id_) { + // Don't require the player ID function in the case of mock players + var id = player && player.id && player.id() || 'no_player'; + this.id_ = id + "_component_" + newGUID(); + } + + this.name_ = options.name || null; // Create element if one wasn't provided in options + + if (options.el) { + this.el_ = options.el; + } else if (options.createEl !== false) { + this.el_ = this.createEl(); + } // if evented is anything except false, we want to mixin in evented + + + if (options.evented !== false) { + // Make this an evented object and use `el_`, if available, as its event bus + evented(this, { + eventBusKey: this.el_ ? 'el_' : null + }); + } + + stateful(this, this.constructor.defaultState); + this.children_ = []; + this.childIndex_ = {}; + this.childNameIndex_ = {}; + var SetSham; + + if (!window$1.Set) { + SetSham = + /*#__PURE__*/ + function () { + function SetSham() { + this.set_ = {}; + } + + var _proto2 = SetSham.prototype; + + _proto2.has = function has(key) { + return key in this.set_; + }; + + _proto2["delete"] = function _delete(key) { + var has = this.has(key); + delete this.set_[key]; + return has; + }; + + _proto2.add = function add(key) { + this.set_[key] = 1; + return this; + }; + + _proto2.forEach = function forEach(callback, thisArg) { + for (var key in this.set_) { + callback.call(thisArg, key, key, this); + } + }; + + return SetSham; + }(); + } + + this.setTimeoutIds_ = window$1.Set ? new Set() : new SetSham(); + this.setIntervalIds_ = window$1.Set ? new Set() : new SetSham(); + this.rafIds_ = window$1.Set ? new Set() : new SetSham(); + this.clearingTimersOnDispose_ = false; // Add any child components in options + + if (options.initChildren !== false) { + this.initChildren(); + } + + this.ready(ready); // Don't want to trigger ready here or it will before init is actually + // finished for all children that run this constructor + + if (options.reportTouchActivity !== false) { + this.enableTouchActivity(); + } + } + /** + * Dispose of the `Component` and all child components. + * + * @fires Component#dispose + */ + + + var _proto = Component.prototype; + + _proto.dispose = function dispose() { + // Bail out if the component has already been disposed. + if (this.isDisposed_) { + return; + } + /** + * Triggered when a `Component` is disposed. + * + * @event Component#dispose + * @type {EventTarget~Event} + * + * @property {boolean} [bubbles=false] + * set to false so that the dispose event does not + * bubble up + */ + + + this.trigger({ + type: 'dispose', + bubbles: false + }); + this.isDisposed_ = true; // Dispose all children. + + if (this.children_) { + for (var i = this.children_.length - 1; i >= 0; i--) { + if (this.children_[i].dispose) { + this.children_[i].dispose(); + } + } + } // Delete child references + + + this.children_ = null; + this.childIndex_ = null; + this.childNameIndex_ = null; + this.parentComponent_ = null; + + if (this.el_) { + // Remove element from DOM + if (this.el_.parentNode) { + this.el_.parentNode.removeChild(this.el_); + } + + if (DomData.has(this.el_)) { + DomData["delete"](this.el_); + } + + this.el_ = null; + } // remove reference to the player after disposing of the element + + + this.player_ = null; + } + /** + * Determine whether or not this component has been disposed. + * + * @return {boolean} + * If the component has been disposed, will be `true`. Otherwise, `false`. + */ + ; + + _proto.isDisposed = function isDisposed() { + return Boolean(this.isDisposed_); + } + /** + * Return the {@link Player} that the `Component` has attached to. + * + * @return {Player} + * The player that this `Component` has attached to. + */ + ; + + _proto.player = function player() { + return this.player_; + } + /** + * Deep merge of options objects with new options. + * > Note: When both `obj` and `options` contain properties whose values are objects. + * The two properties get merged using {@link module:mergeOptions} + * + * @param {Object} obj + * The object that contains new options. + * + * @return {Object} + * A new object of `this.options_` and `obj` merged together. + */ + ; + + _proto.options = function options(obj) { + if (!obj) { + return this.options_; + } + + this.options_ = mergeOptions(this.options_, obj); + return this.options_; + } + /** + * Get the `Component`s DOM element + * + * @return {Element} + * The DOM element for this `Component`. + */ + ; + + _proto.el = function el() { + return this.el_; + } + /** + * Create the `Component`s DOM element. + * + * @param {string} [tagName] + * Element's DOM node type. e.g. 'div' + * + * @param {Object} [properties] + * An object of properties that should be set. + * + * @param {Object} [attributes] + * An object of attributes that should be set. + * + * @return {Element} + * The element that gets created. + */ + ; + + _proto.createEl = function createEl$1(tagName, properties, attributes) { + return createEl(tagName, properties, attributes); + } + /** + * Localize a string given the string in english. + * + * If tokens are provided, it'll try and run a simple token replacement on the provided string. + * The tokens it looks for look like `{1}` with the index being 1-indexed into the tokens array. + * + * If a `defaultValue` is provided, it'll use that over `string`, + * if a value isn't found in provided language files. + * This is useful if you want to have a descriptive key for token replacement + * but have a succinct localized string and not require `en.json` to be included. + * + * Currently, it is used for the progress bar timing. + * ```js + * { + * "progress bar timing: currentTime={1} duration={2}": "{1} of {2}" + * } + * ``` + * It is then used like so: + * ```js + * this.localize('progress bar timing: currentTime={1} duration{2}', + * [this.player_.currentTime(), this.player_.duration()], + * '{1} of {2}'); + * ``` + * + * Which outputs something like: `01:23 of 24:56`. + * + * + * @param {string} string + * The string to localize and the key to lookup in the language files. + * @param {string[]} [tokens] + * If the current item has token replacements, provide the tokens here. + * @param {string} [defaultValue] + * Defaults to `string`. Can be a default value to use for token replacement + * if the lookup key is needed to be separate. + * + * @return {string} + * The localized string or if no localization exists the english string. + */ + ; + + _proto.localize = function localize(string, tokens, defaultValue) { + if (defaultValue === void 0) { + defaultValue = string; + } + + var code = this.player_.language && this.player_.language(); + var languages = this.player_.languages && this.player_.languages(); + var language = languages && languages[code]; + var primaryCode = code && code.split('-')[0]; + var primaryLang = languages && languages[primaryCode]; + var localizedString = defaultValue; + + if (language && language[string]) { + localizedString = language[string]; + } else if (primaryLang && primaryLang[string]) { + localizedString = primaryLang[string]; + } + + if (tokens) { + localizedString = localizedString.replace(/\{(\d+)\}/g, function (match, index) { + var value = tokens[index - 1]; + var ret = value; + + if (typeof value === 'undefined') { + ret = match; + } + + return ret; + }); + } + + return localizedString; + } + /** + * Return the `Component`s DOM element. This is where children get inserted. + * This will usually be the the same as the element returned in {@link Component#el}. + * + * @return {Element} + * The content element for this `Component`. + */ + ; + + _proto.contentEl = function contentEl() { + return this.contentEl_ || this.el_; + } + /** + * Get this `Component`s ID + * + * @return {string} + * The id of this `Component` + */ + ; + + _proto.id = function id() { + return this.id_; + } + /** + * Get the `Component`s name. The name gets used to reference the `Component` + * and is set during registration. + * + * @return {string} + * The name of this `Component`. + */ + ; + + _proto.name = function name() { + return this.name_; + } + /** + * Get an array of all child components + * + * @return {Array} + * The children + */ + ; + + _proto.children = function children() { + return this.children_; + } + /** + * Returns the child `Component` with the given `id`. + * + * @param {string} id + * The id of the child `Component` to get. + * + * @return {Component|undefined} + * The child `Component` with the given `id` or undefined. + */ + ; + + _proto.getChildById = function getChildById(id) { + return this.childIndex_[id]; + } + /** + * Returns the child `Component` with the given `name`. + * + * @param {string} name + * The name of the child `Component` to get. + * + * @return {Component|undefined} + * The child `Component` with the given `name` or undefined. + */ + ; + + _proto.getChild = function getChild(name) { + if (!name) { + return; + } + + return this.childNameIndex_[name]; + } + /** + * Add a child `Component` inside the current `Component`. + * + * + * @param {string|Component} child + * The name or instance of a child to add. + * + * @param {Object} [options={}] + * The key/value store of options that will get passed to children of + * the child. + * + * @param {number} [index=this.children_.length] + * The index to attempt to add a child into. + * + * @return {Component} + * The `Component` that gets added as a child. When using a string the + * `Component` will get created by this process. + */ + ; + + _proto.addChild = function addChild(child, options, index) { + if (options === void 0) { + options = {}; + } + + if (index === void 0) { + index = this.children_.length; + } + + var component; + var componentName; // If child is a string, create component with options + + if (typeof child === 'string') { + componentName = toTitleCase(child); + var componentClassName = options.componentClass || componentName; // Set name through options + + options.name = componentName; // Create a new object & element for this controls set + // If there's no .player_, this is a player + + var ComponentClass = Component.getComponent(componentClassName); + + if (!ComponentClass) { + throw new Error("Component " + componentClassName + " does not exist"); + } // data stored directly on the videojs object may be + // misidentified as a component to retain + // backwards-compatibility with 4.x. check to make sure the + // component class can be instantiated. + + + if (typeof ComponentClass !== 'function') { + return null; + } + + component = new ComponentClass(this.player_ || this, options); // child is a component instance + } else { + component = child; + } + + if (component.parentComponent_) { + component.parentComponent_.removeChild(component); + } + + this.children_.splice(index, 0, component); + component.parentComponent_ = this; + + if (typeof component.id === 'function') { + this.childIndex_[component.id()] = component; + } // If a name wasn't used to create the component, check if we can use the + // name function of the component + + + componentName = componentName || component.name && toTitleCase(component.name()); + + if (componentName) { + this.childNameIndex_[componentName] = component; + this.childNameIndex_[toLowerCase(componentName)] = component; + } // Add the UI object's element to the container div (box) + // Having an element is not required + + + if (typeof component.el === 'function' && component.el()) { + // If inserting before a component, insert before that component's element + var refNode = null; + + if (this.children_[index + 1] && this.children_[index + 1].el_) { + refNode = this.children_[index + 1].el_; + } + + this.contentEl().insertBefore(component.el(), refNode); + } // Return so it can stored on parent object if desired. + + + return component; + } + /** + * Remove a child `Component` from this `Component`s list of children. Also removes + * the child `Component`s element from this `Component`s element. + * + * @param {Component} component + * The child `Component` to remove. + */ + ; + + _proto.removeChild = function removeChild(component) { + if (typeof component === 'string') { + component = this.getChild(component); + } + + if (!component || !this.children_) { + return; + } + + var childFound = false; + + for (var i = this.children_.length - 1; i >= 0; i--) { + if (this.children_[i] === component) { + childFound = true; + this.children_.splice(i, 1); + break; + } + } + + if (!childFound) { + return; + } + + component.parentComponent_ = null; + this.childIndex_[component.id()] = null; + this.childNameIndex_[toTitleCase(component.name())] = null; + this.childNameIndex_[toLowerCase(component.name())] = null; + var compEl = component.el(); + + if (compEl && compEl.parentNode === this.contentEl()) { + this.contentEl().removeChild(component.el()); + } + } + /** + * Add and initialize default child `Component`s based upon options. + */ + ; + + _proto.initChildren = function initChildren() { + var _this = this; + + var children = this.options_.children; + + if (children) { + // `this` is `parent` + var parentOptions = this.options_; + + var handleAdd = function handleAdd(child) { + var name = child.name; + var opts = child.opts; // Allow options for children to be set at the parent options + // e.g. videojs(id, { controlBar: false }); + // instead of videojs(id, { children: { controlBar: false }); + + if (parentOptions[name] !== undefined) { + opts = parentOptions[name]; + } // Allow for disabling default components + // e.g. options['children']['posterImage'] = false + + + if (opts === false) { + return; + } // Allow options to be passed as a simple boolean if no configuration + // is necessary. + + + if (opts === true) { + opts = {}; + } // We also want to pass the original player options + // to each component as well so they don't need to + // reach back into the player for options later. + + + opts.playerOptions = _this.options_.playerOptions; // Create and add the child component. + // Add a direct reference to the child by name on the parent instance. + // If two of the same component are used, different names should be supplied + // for each + + var newChild = _this.addChild(name, opts); + + if (newChild) { + _this[name] = newChild; + } + }; // Allow for an array of children details to passed in the options + + + var workingChildren; + var Tech = Component.getComponent('Tech'); + + if (Array.isArray(children)) { + workingChildren = children; + } else { + workingChildren = Object.keys(children); + } + + workingChildren // children that are in this.options_ but also in workingChildren would + // give us extra children we do not want. So, we want to filter them out. + .concat(Object.keys(this.options_).filter(function (child) { + return !workingChildren.some(function (wchild) { + if (typeof wchild === 'string') { + return child === wchild; + } + + return child === wchild.name; + }); + })).map(function (child) { + var name; + var opts; + + if (typeof child === 'string') { + name = child; + opts = children[name] || _this.options_[name] || {}; + } else { + name = child.name; + opts = child; + } + + return { + name: name, + opts: opts + }; + }).filter(function (child) { + // we have to make sure that child.name isn't in the techOrder since + // techs are registerd as Components but can't aren't compatible + // See https://github.com/videojs/video.js/issues/2772 + var c = Component.getComponent(child.opts.componentClass || toTitleCase(child.name)); + return c && !Tech.isTech(c); + }).forEach(handleAdd); + } + } + /** + * Builds the default DOM class name. Should be overriden by sub-components. + * + * @return {string} + * The DOM class name for this object. + * + * @abstract + */ + ; + + _proto.buildCSSClass = function buildCSSClass() { + // Child classes can include a function that does: + // return 'CLASS NAME' + this._super(); + return ''; + } + /** + * Bind a listener to the component's ready state. + * Different from event listeners in that if the ready event has already happened + * it will trigger the function immediately. + * + * @return {Component} + * Returns itself; method can be chained. + */ + ; + + _proto.ready = function ready(fn, sync) { + if (sync === void 0) { + sync = false; + } + + if (!fn) { + return; + } + + if (!this.isReady_) { + this.readyQueue_ = this.readyQueue_ || []; + this.readyQueue_.push(fn); + return; + } + + if (sync) { + fn.call(this); + } else { + // Call the function asynchronously by default for consistency + this.setTimeout(fn, 1); + } + } + /** + * Trigger all the ready listeners for this `Component`. + * + * @fires Component#ready + */ + ; + + _proto.triggerReady = function triggerReady() { + this.isReady_ = true; // Ensure ready is triggered asynchronously + + this.setTimeout(function () { + var readyQueue = this.readyQueue_; // Reset Ready Queue + + this.readyQueue_ = []; + + if (readyQueue && readyQueue.length > 0) { + readyQueue.forEach(function (fn) { + fn.call(this); + }, this); + } // Allow for using event listeners also + + /** + * Triggered when a `Component` is ready. + * + * @event Component#ready + * @type {EventTarget~Event} + */ + + + this.trigger('ready'); + }, 1); + } + /** + * Find a single DOM element matching a `selector`. This can be within the `Component`s + * `contentEl()` or another custom context. + * + * @param {string} selector + * A valid CSS selector, which will be passed to `querySelector`. + * + * @param {Element|string} [context=this.contentEl()] + * A DOM element within which to query. Can also be a selector string in + * which case the first matching element will get used as context. If + * missing `this.contentEl()` gets used. If `this.contentEl()` returns + * nothing it falls back to `document`. + * + * @return {Element|null} + * the dom element that was found, or null + * + * @see [Information on CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) + */ + ; + + _proto.$ = function $$1(selector, context) { + return $(selector, context || this.contentEl()); + } + /** + * Finds all DOM element matching a `selector`. This can be within the `Component`s + * `contentEl()` or another custom context. + * + * @param {string} selector + * A valid CSS selector, which will be passed to `querySelectorAll`. + * + * @param {Element|string} [context=this.contentEl()] + * A DOM element within which to query. Can also be a selector string in + * which case the first matching element will get used as context. If + * missing `this.contentEl()` gets used. If `this.contentEl()` returns + * nothing it falls back to `document`. + * + * @return {NodeList} + * a list of dom elements that were found + * + * @see [Information on CSS Selectors](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) + */ + ; + + _proto.$$ = function $$$1(selector, context) { + return $$(selector, context || this.contentEl()); + } + /** + * Check if a component's element has a CSS class name. + * + * @param {string} classToCheck + * CSS class name to check. + * + * @return {boolean} + * - True if the `Component` has the class. + * - False if the `Component` does not have the class` + */ + ; + + _proto.hasClass = function hasClass$1(classToCheck) { + return hasClass(this.el_, classToCheck); + } + /** + * Add a CSS class name to the `Component`s element. + * + * @param {string} classToAdd + * CSS class name to add + */ + ; + + _proto.addClass = function addClass$1(classToAdd) { + addClass(this.el_, classToAdd); + } + /** + * Remove a CSS class name from the `Component`s element. + * + * @param {string} classToRemove + * CSS class name to remove + */ + ; + + _proto.removeClass = function removeClass$1(classToRemove) { + removeClass(this.el_, classToRemove); + } + /** + * Add or remove a CSS class name from the component's element. + * - `classToToggle` gets added when {@link Component#hasClass} would return false. + * - `classToToggle` gets removed when {@link Component#hasClass} would return true. + * + * @param {string} classToToggle + * The class to add or remove based on (@link Component#hasClass} + * + * @param {boolean|Dom~predicate} [predicate] + * An {@link Dom~predicate} function or a boolean + */ + ; + + _proto.toggleClass = function toggleClass$1(classToToggle, predicate) { + toggleClass(this.el_, classToToggle, predicate); + } + /** + * Show the `Component`s element if it is hidden by removing the + * 'vjs-hidden' class name from it. + */ + ; + + _proto.show = function show() { + this.removeClass('vjs-hidden'); + } + /** + * Hide the `Component`s element if it is currently showing by adding the + * 'vjs-hidden` class name to it. + */ + ; + + _proto.hide = function hide() { + this.addClass('vjs-hidden'); + } + /** + * Lock a `Component`s element in its visible state by adding the 'vjs-lock-showing' + * class name to it. Used during fadeIn/fadeOut. + * + * @private + */ + ; + + _proto.lockShowing = function lockShowing() { + this.addClass('vjs-lock-showing'); + } + /** + * Unlock a `Component`s element from its visible state by removing the 'vjs-lock-showing' + * class name from it. Used during fadeIn/fadeOut. + * + * @private + */ + ; + + _proto.unlockShowing = function unlockShowing() { + this.removeClass('vjs-lock-showing'); + } + /** + * Get the value of an attribute on the `Component`s element. + * + * @param {string} attribute + * Name of the attribute to get the value from. + * + * @return {string|null} + * - The value of the attribute that was asked for. + * - Can be an empty string on some browsers if the attribute does not exist + * or has no value + * - Most browsers will return null if the attibute does not exist or has + * no value. + * + * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute} + */ + ; + + _proto.getAttribute = function getAttribute$1(attribute) { + return getAttribute(this.el_, attribute); + } + /** + * Set the value of an attribute on the `Component`'s element + * + * @param {string} attribute + * Name of the attribute to set. + * + * @param {string} value + * Value to set the attribute to. + * + * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute} + */ + ; + + _proto.setAttribute = function setAttribute$1(attribute, value) { + setAttribute(this.el_, attribute, value); + } + /** + * Remove an attribute from the `Component`s element. + * + * @param {string} attribute + * Name of the attribute to remove. + * + * @see [DOM API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/removeAttribute} + */ + ; + + _proto.removeAttribute = function removeAttribute$1(attribute) { + removeAttribute(this.el_, attribute); + } + /** + * Get or set the width of the component based upon the CSS styles. + * See {@link Component#dimension} for more detailed information. + * + * @param {number|string} [num] + * The width that you want to set postfixed with '%', 'px' or nothing. + * + * @param {boolean} [skipListeners] + * Skip the componentresize event trigger + * + * @return {number|string} + * The width when getting, zero if there is no width. Can be a string + * postpixed with '%' or 'px'. + */ + ; + + _proto.width = function width(num, skipListeners) { + return this.dimension('width', num, skipListeners); + } + /** + * Get or set the height of the component based upon the CSS styles. + * See {@link Component#dimension} for more detailed information. + * + * @param {number|string} [num] + * The height that you want to set postfixed with '%', 'px' or nothing. + * + * @param {boolean} [skipListeners] + * Skip the componentresize event trigger + * + * @return {number|string} + * The width when getting, zero if there is no width. Can be a string + * postpixed with '%' or 'px'. + */ + ; + + _proto.height = function height(num, skipListeners) { + return this.dimension('height', num, skipListeners); + } + /** + * Set both the width and height of the `Component` element at the same time. + * + * @param {number|string} width + * Width to set the `Component`s element to. + * + * @param {number|string} height + * Height to set the `Component`s element to. + */ + ; + + _proto.dimensions = function dimensions(width, height) { + // Skip componentresize listeners on width for optimization + this.width(width, true); + this.height(height); + } + /** + * Get or set width or height of the `Component` element. This is the shared code + * for the {@link Component#width} and {@link Component#height}. + * + * Things to know: + * - If the width or height in an number this will return the number postfixed with 'px'. + * - If the width/height is a percent this will return the percent postfixed with '%' + * - Hidden elements have a width of 0 with `window.getComputedStyle`. This function + * defaults to the `Component`s `style.width` and falls back to `window.getComputedStyle`. + * See [this]{@link http://www.foliotek.com/devblog/getting-the-width-of-a-hidden-element-with-jquery-using-width/} + * for more information + * - If you want the computed style of the component, use {@link Component#currentWidth} + * and {@link {Component#currentHeight} + * + * @fires Component#componentresize + * + * @param {string} widthOrHeight + 8 'width' or 'height' + * + * @param {number|string} [num] + 8 New dimension + * + * @param {boolean} [skipListeners] + * Skip componentresize event trigger + * + * @return {number} + * The dimension when getting or 0 if unset + */ + ; + + _proto.dimension = function dimension(widthOrHeight, num, skipListeners) { + if (num !== undefined) { + // Set to zero if null or literally NaN (NaN !== NaN) + if (num === null || num !== num) { + num = 0; + } // Check if using css width/height (% or px) and adjust + + + if (('' + num).indexOf('%') !== -1 || ('' + num).indexOf('px') !== -1) { + this.el_.style[widthOrHeight] = num; + } else if (num === 'auto') { + this.el_.style[widthOrHeight] = ''; + } else { + this.el_.style[widthOrHeight] = num + 'px'; + } // skipListeners allows us to avoid triggering the resize event when setting both width and height + + + if (!skipListeners) { + /** + * Triggered when a component is resized. + * + * @event Component#componentresize + * @type {EventTarget~Event} + */ + this.trigger('componentresize'); + } + + return; + } // Not setting a value, so getting it + // Make sure element exists + + + if (!this.el_) { + return 0; + } // Get dimension value from style + + + var val = this.el_.style[widthOrHeight]; + var pxIndex = val.indexOf('px'); + + if (pxIndex !== -1) { + // Return the pixel value with no 'px' + return parseInt(val.slice(0, pxIndex), 10); + } // No px so using % or no style was set, so falling back to offsetWidth/height + // If component has display:none, offset will return 0 + // TODO: handle display:none and no dimension style using px + + + return parseInt(this.el_['offset' + toTitleCase(widthOrHeight)], 10); + } + /** + * Get the computed width or the height of the component's element. + * + * Uses `window.getComputedStyle`. + * + * @param {string} widthOrHeight + * A string containing 'width' or 'height'. Whichever one you want to get. + * + * @return {number} + * The dimension that gets asked for or 0 if nothing was set + * for that dimension. + */ + ; + + _proto.currentDimension = function currentDimension(widthOrHeight) { + var computedWidthOrHeight = 0; + + if (widthOrHeight !== 'width' && widthOrHeight !== 'height') { + throw new Error('currentDimension only accepts width or height value'); + } + + computedWidthOrHeight = computedStyle(this.el_, widthOrHeight); // remove 'px' from variable and parse as integer + + computedWidthOrHeight = parseFloat(computedWidthOrHeight); // if the computed value is still 0, it's possible that the browser is lying + // and we want to check the offset values. + // This code also runs wherever getComputedStyle doesn't exist. + + if (computedWidthOrHeight === 0 || isNaN(computedWidthOrHeight)) { + var rule = "offset" + toTitleCase(widthOrHeight); + computedWidthOrHeight = this.el_[rule]; + } + + return computedWidthOrHeight; + } + /** + * An object that contains width and height values of the `Component`s + * computed style. Uses `window.getComputedStyle`. + * + * @typedef {Object} Component~DimensionObject + * + * @property {number} width + * The width of the `Component`s computed style. + * + * @property {number} height + * The height of the `Component`s computed style. + */ + + /** + * Get an object that contains computed width and height values of the + * component's element. + * + * Uses `window.getComputedStyle`. + * + * @return {Component~DimensionObject} + * The computed dimensions of the component's element. + */ + ; + + _proto.currentDimensions = function currentDimensions() { + return { + width: this.currentDimension('width'), + height: this.currentDimension('height') + }; + } + /** + * Get the computed width of the component's element. + * + * Uses `window.getComputedStyle`. + * + * @return {number} + * The computed width of the component's element. + */ + ; + + _proto.currentWidth = function currentWidth() { + return this.currentDimension('width'); + } + /** + * Get the computed height of the component's element. + * + * Uses `window.getComputedStyle`. + * + * @return {number} + * The computed height of the component's element. + */ + ; + + _proto.currentHeight = function currentHeight() { + return this.currentDimension('height'); + } + /** + * Set the focus to this component + */ + ; + + _proto.focus = function focus() { + this.el_.focus(); + } + /** + * Remove the focus from this component + */ + ; + + _proto.blur = function blur() { + this.el_.blur(); + } + /** + * When this Component receives a `keydown` event which it does not process, + * it passes the event to the Player for handling. + * + * @param {EventTarget~Event} event + * The `keydown` event that caused this function to be called. + */ + ; + + _proto.handleKeyDown = function handleKeyDown(event) { + if (this.player_) { + // We only stop propagation here because we want unhandled events to fall + // back to the browser. + event.stopPropagation(); + this.player_.handleKeyDown(event); + } + } + /** + * Many components used to have a `handleKeyPress` method, which was poorly + * named because it listened to a `keydown` event. This method name now + * delegates to `handleKeyDown`. This means anyone calling `handleKeyPress` + * will not see their method calls stop working. + * + * @param {EventTarget~Event} event + * The event that caused this function to be called. + */ + ; + + _proto.handleKeyPress = function handleKeyPress(event) { + this.handleKeyDown(event); + } + /** + * Emit a 'tap' events when touch event support gets detected. This gets used to + * support toggling the controls through a tap on the video. They get enabled + * because every sub-component would have extra overhead otherwise. + * + * @private + * @fires Component#tap + * @listens Component#touchstart + * @listens Component#touchmove + * @listens Component#touchleave + * @listens Component#touchcancel + * @listens Component#touchend + */ + ; + + _proto.emitTapEvents = function emitTapEvents() { + // Track the start time so we can determine how long the touch lasted + var touchStart = 0; + var firstTouch = null; // Maximum movement allowed during a touch event to still be considered a tap + // Other popular libs use anywhere from 2 (hammer.js) to 15, + // so 10 seems like a nice, round number. + + var tapMovementThreshold = 10; // The maximum length a touch can be while still being considered a tap + + var touchTimeThreshold = 200; + var couldBeTap; + this.on('touchstart', function (event) { + // If more than one finger, don't consider treating this as a click + if (event.touches.length === 1) { + // Copy pageX/pageY from the object + firstTouch = { + pageX: event.touches[0].pageX, + pageY: event.touches[0].pageY + }; // Record start time so we can detect a tap vs. "touch and hold" + + touchStart = window$1.performance.now(); // Reset couldBeTap tracking + + couldBeTap = true; + } + }); + this.on('touchmove', function (event) { + // If more than one finger, don't consider treating this as a click + if (event.touches.length > 1) { + couldBeTap = false; + } else if (firstTouch) { + // Some devices will throw touchmoves for all but the slightest of taps. + // So, if we moved only a small distance, this could still be a tap + var xdiff = event.touches[0].pageX - firstTouch.pageX; + var ydiff = event.touches[0].pageY - firstTouch.pageY; + var touchDistance = Math.sqrt(xdiff * xdiff + ydiff * ydiff); + + if (touchDistance > tapMovementThreshold) { + couldBeTap = false; + } + } + }); + + var noTap = function noTap() { + couldBeTap = false; + }; // TODO: Listen to the original target. http://youtu.be/DujfpXOKUp8?t=13m8s + + + this.on('touchleave', noTap); + this.on('touchcancel', noTap); // When the touch ends, measure how long it took and trigger the appropriate + // event + + this.on('touchend', function (event) { + firstTouch = null; // Proceed only if the touchmove/leave/cancel event didn't happen + + if (couldBeTap === true) { + // Measure how long the touch lasted + var touchTime = window$1.performance.now() - touchStart; // Make sure the touch was less than the threshold to be considered a tap + + if (touchTime < touchTimeThreshold) { + // Don't let browser turn this into a click + event.preventDefault(); + /** + * Triggered when a `Component` is tapped. + * + * @event Component#tap + * @type {EventTarget~Event} + */ + + this.trigger('tap'); // It may be good to copy the touchend event object and change the + // type to tap, if the other event properties aren't exact after + // Events.fixEvent runs (e.g. event.target) + } + } + }); + } + /** + * This function reports user activity whenever touch events happen. This can get + * turned off by any sub-components that wants touch events to act another way. + * + * Report user touch activity when touch events occur. User activity gets used to + * determine when controls should show/hide. It is simple when it comes to mouse + * events, because any mouse event should show the controls. So we capture mouse + * events that bubble up to the player and report activity when that happens. + * With touch events it isn't as easy as `touchstart` and `touchend` toggle player + * controls. So touch events can't help us at the player level either. + * + * User activity gets checked asynchronously. So what could happen is a tap event + * on the video turns the controls off. Then the `touchend` event bubbles up to + * the player. Which, if it reported user activity, would turn the controls right + * back on. We also don't want to completely block touch events from bubbling up. + * Furthermore a `touchmove` event and anything other than a tap, should not turn + * controls back on. + * + * @listens Component#touchstart + * @listens Component#touchmove + * @listens Component#touchend + * @listens Component#touchcancel + */ + ; + + _proto.enableTouchActivity = function enableTouchActivity() { + // Don't continue if the root player doesn't support reporting user activity + if (!this.player() || !this.player().reportUserActivity) { + return; + } // listener for reporting that the user is active + + + var report = bind(this.player(), this.player().reportUserActivity); + var touchHolding; + this.on('touchstart', function () { + report(); // For as long as the they are touching the device or have their mouse down, + // we consider them active even if they're not moving their finger or mouse. + // So we want to continue to update that they are active + + this.clearInterval(touchHolding); // report at the same interval as activityCheck + + touchHolding = this.setInterval(report, 250); + }); + + var touchEnd = function touchEnd(event) { + report(); // stop the interval that maintains activity if the touch is holding + + this.clearInterval(touchHolding); + }; + + this.on('touchmove', report); + this.on('touchend', touchEnd); + this.on('touchcancel', touchEnd); + } + /** + * A callback that has no parameters and is bound into `Component`s context. + * + * @callback Component~GenericCallback + * @this Component + */ + + /** + * Creates a function that runs after an `x` millisecond timeout. This function is a + * wrapper around `window.setTimeout`. There are a few reasons to use this one + * instead though: + * 1. It gets cleared via {@link Component#clearTimeout} when + * {@link Component#dispose} gets called. + * 2. The function callback will gets turned into a {@link Component~GenericCallback} + * + * > Note: You can't use `window.clearTimeout` on the id returned by this function. This + * will cause its dispose listener not to get cleaned up! Please use + * {@link Component#clearTimeout} or {@link Component#dispose} instead. + * + * @param {Component~GenericCallback} fn + * The function that will be run after `timeout`. + * + * @param {number} timeout + * Timeout in milliseconds to delay before executing the specified function. + * + * @return {number} + * Returns a timeout ID that gets used to identify the timeout. It can also + * get used in {@link Component#clearTimeout} to clear the timeout that + * was set. + * + * @listens Component#dispose + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/setTimeout} + */ + ; + + _proto.setTimeout = function setTimeout(fn, timeout) { + var _this2 = this; + + // declare as variables so they are properly available in timeout function + // eslint-disable-next-line + var timeoutId; + fn = bind(this, fn); + this.clearTimersOnDispose_(); + timeoutId = window$1.setTimeout(function () { + if (_this2.setTimeoutIds_.has(timeoutId)) { + _this2.setTimeoutIds_["delete"](timeoutId); + } + + fn(); + }, timeout); + this.setTimeoutIds_.add(timeoutId); + return timeoutId; + } + /** + * Clears a timeout that gets created via `window.setTimeout` or + * {@link Component#setTimeout}. If you set a timeout via {@link Component#setTimeout} + * use this function instead of `window.clearTimout`. If you don't your dispose + * listener will not get cleaned up until {@link Component#dispose}! + * + * @param {number} timeoutId + * The id of the timeout to clear. The return value of + * {@link Component#setTimeout} or `window.setTimeout`. + * + * @return {number} + * Returns the timeout id that was cleared. + * + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/clearTimeout} + */ + ; + + _proto.clearTimeout = function clearTimeout(timeoutId) { + if (this.setTimeoutIds_.has(timeoutId)) { + this.setTimeoutIds_["delete"](timeoutId); + window$1.clearTimeout(timeoutId); + } + + return timeoutId; + } + /** + * Creates a function that gets run every `x` milliseconds. This function is a wrapper + * around `window.setInterval`. There are a few reasons to use this one instead though. + * 1. It gets cleared via {@link Component#clearInterval} when + * {@link Component#dispose} gets called. + * 2. The function callback will be a {@link Component~GenericCallback} + * + * @param {Component~GenericCallback} fn + * The function to run every `x` seconds. + * + * @param {number} interval + * Execute the specified function every `x` milliseconds. + * + * @return {number} + * Returns an id that can be used to identify the interval. It can also be be used in + * {@link Component#clearInterval} to clear the interval. + * + * @listens Component#dispose + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/setInterval} + */ + ; + + _proto.setInterval = function setInterval(fn, interval) { + fn = bind(this, fn); + this.clearTimersOnDispose_(); + var intervalId = window$1.setInterval(fn, interval); + this.setIntervalIds_.add(intervalId); + return intervalId; + } + /** + * Clears an interval that gets created via `window.setInterval` or + * {@link Component#setInterval}. If you set an inteval via {@link Component#setInterval} + * use this function instead of `window.clearInterval`. If you don't your dispose + * listener will not get cleaned up until {@link Component#dispose}! + * + * @param {number} intervalId + * The id of the interval to clear. The return value of + * {@link Component#setInterval} or `window.setInterval`. + * + * @return {number} + * Returns the interval id that was cleared. + * + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/clearInterval} + */ + ; + + _proto.clearInterval = function clearInterval(intervalId) { + if (this.setIntervalIds_.has(intervalId)) { + this.setIntervalIds_["delete"](intervalId); + window$1.clearInterval(intervalId); + } + + return intervalId; + } + /** + * Queues up a callback to be passed to requestAnimationFrame (rAF), but + * with a few extra bonuses: + * + * - Supports browsers that do not support rAF by falling back to + * {@link Component#setTimeout}. + * + * - The callback is turned into a {@link Component~GenericCallback} (i.e. + * bound to the component). + * + * - Automatic cancellation of the rAF callback is handled if the component + * is disposed before it is called. + * + * @param {Component~GenericCallback} fn + * A function that will be bound to this component and executed just + * before the browser's next repaint. + * + * @return {number} + * Returns an rAF ID that gets used to identify the timeout. It can + * also be used in {@link Component#cancelAnimationFrame} to cancel + * the animation frame callback. + * + * @listens Component#dispose + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame} + */ + ; + + _proto.requestAnimationFrame = function requestAnimationFrame(fn) { + var _this3 = this; + + // Fall back to using a timer. + if (!this.supportsRaf_) { + return this.setTimeout(fn, 1000 / 60); + } + + this.clearTimersOnDispose_(); // declare as variables so they are properly available in rAF function + // eslint-disable-next-line + + var id; + fn = bind(this, fn); + id = window$1.requestAnimationFrame(function () { + if (_this3.rafIds_.has(id)) { + _this3.rafIds_["delete"](id); + } + + fn(); + }); + this.rafIds_.add(id); + return id; + } + /** + * Cancels a queued callback passed to {@link Component#requestAnimationFrame} + * (rAF). + * + * If you queue an rAF callback via {@link Component#requestAnimationFrame}, + * use this function instead of `window.cancelAnimationFrame`. If you don't, + * your dispose listener will not get cleaned up until {@link Component#dispose}! + * + * @param {number} id + * The rAF ID to clear. The return value of {@link Component#requestAnimationFrame}. + * + * @return {number} + * Returns the rAF ID that was cleared. + * + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/cancelAnimationFrame} + */ + ; + + _proto.cancelAnimationFrame = function cancelAnimationFrame(id) { + // Fall back to using a timer. + if (!this.supportsRaf_) { + return this.clearTimeout(id); + } + + if (this.rafIds_.has(id)) { + this.rafIds_["delete"](id); + window$1.cancelAnimationFrame(id); + } + + return id; + } + /** + * A function to setup `requestAnimationFrame`, `setTimeout`, + * and `setInterval`, clearing on dispose. + * + * > Previously each timer added and removed dispose listeners on it's own. + * For better performance it was decided to batch them all, and use `Set`s + * to track outstanding timer ids. + * + * @private + */ + ; + + _proto.clearTimersOnDispose_ = function clearTimersOnDispose_() { + var _this4 = this; + + if (this.clearingTimersOnDispose_) { + return; + } + + this.clearingTimersOnDispose_ = true; + this.one('dispose', function () { + [['rafIds_', 'cancelAnimationFrame'], ['setTimeoutIds_', 'clearTimeout'], ['setIntervalIds_', 'clearInterval']].forEach(function (_ref) { + var idName = _ref[0], + cancelName = _ref[1]; + + _this4[idName].forEach(_this4[cancelName], _this4); + }); + _this4.clearingTimersOnDispose_ = false; + }); + } + /** + * Register a `Component` with `videojs` given the name and the component. + * + * > NOTE: {@link Tech}s should not be registered as a `Component`. {@link Tech}s + * should be registered using {@link Tech.registerTech} or + * {@link videojs:videojs.registerTech}. + * + * > NOTE: This function can also be seen on videojs as + * {@link videojs:videojs.registerComponent}. + * + * @param {string} name + * The name of the `Component` to register. + * + * @param {Component} ComponentToRegister + * The `Component` class to register. + * + * @return {Component} + * The `Component` that was registered. + */ + ; + + Component.registerComponent = function registerComponent(name, ComponentToRegister) { + if (typeof name !== 'string' || !name) { + throw new Error("Illegal component name, \"" + name + "\"; must be a non-empty string."); + } + + var Tech = Component.getComponent('Tech'); // We need to make sure this check is only done if Tech has been registered. + + var isTech = Tech && Tech.isTech(ComponentToRegister); + var isComp = Component === ComponentToRegister || Component.prototype.isPrototypeOf(ComponentToRegister.prototype); + + if (isTech || !isComp) { + var reason; + + if (isTech) { + reason = 'techs must be registered using Tech.registerTech()'; + } else { + reason = 'must be a Component subclass'; + } + + throw new Error("Illegal component, \"" + name + "\"; " + reason + "."); + } + + name = toTitleCase(name); + + if (!Component.components_) { + Component.components_ = {}; + } + + var Player = Component.getComponent('Player'); + + if (name === 'Player' && Player && Player.players) { + var players = Player.players; + var playerNames = Object.keys(players); // If we have players that were disposed, then their name will still be + // in Players.players. So, we must loop through and verify that the value + // for each item is not null. This allows registration of the Player component + // after all players have been disposed or before any were created. + + if (players && playerNames.length > 0 && playerNames.map(function (pname) { + return players[pname]; + }).every(Boolean)) { + throw new Error('Can not register Player component after player has been created.'); + } + } + + Component.components_[name] = ComponentToRegister; + Component.components_[toLowerCase(name)] = ComponentToRegister; + return ComponentToRegister; + } + /** + * Get a `Component` based on the name it was registered with. + * + * @param {string} name + * The Name of the component to get. + * + * @return {Component} + * The `Component` that got registered under the given name. + * + * @deprecated In `videojs` 6 this will not return `Component`s that were not + * registered using {@link Component.registerComponent}. Currently we + * check the global `videojs` object for a `Component` name and + * return that if it exists. + */ + ; + + Component.getComponent = function getComponent(name) { + if (!name || !Component.components_) { + return; + } + + return Component.components_[name]; + }; + + return Component; + }(); + /** + * Whether or not this component supports `requestAnimationFrame`. + * + * This is exposed primarily for testing purposes. + * + * @private + * @type {Boolean} + */ + + + Component.prototype.supportsRaf_ = typeof window$1.requestAnimationFrame === 'function' && typeof window$1.cancelAnimationFrame === 'function'; + Component.registerComponent('Component', Component); + + function _assertThisInitialized(self) { + if (self === void 0) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return self; + } + + var assertThisInitialized = _assertThisInitialized; + + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + subClass.__proto__ = superClass; + } + + var inheritsLoose = _inheritsLoose; + + /** + * @file browser.js + * @module browser + */ + var USER_AGENT = window$1.navigator && window$1.navigator.userAgent || ''; + var webkitVersionMap = /AppleWebKit\/([\d.]+)/i.exec(USER_AGENT); + var appleWebkitVersion = webkitVersionMap ? parseFloat(webkitVersionMap.pop()) : null; + /** + * Whether or not this device is an iPod. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_IPOD = /iPod/i.test(USER_AGENT); + /** + * The detected iOS version - or `null`. + * + * @static + * @const + * @type {string|null} + */ + + var IOS_VERSION = function () { + var match = USER_AGENT.match(/OS (\d+)_/i); + + if (match && match[1]) { + return match[1]; + } + + return null; + }(); + /** + * Whether or not this is an Android device. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_ANDROID = /Android/i.test(USER_AGENT); + /** + * The detected Android version - or `null`. + * + * @static + * @const + * @type {number|string|null} + */ + + var ANDROID_VERSION = function () { + // This matches Android Major.Minor.Patch versions + // ANDROID_VERSION is Major.Minor as a Number, if Minor isn't available, then only Major is returned + var match = USER_AGENT.match(/Android (\d+)(?:\.(\d+))?(?:\.(\d+))*/i); + + if (!match) { + return null; + } + + var major = match[1] && parseFloat(match[1]); + var minor = match[2] && parseFloat(match[2]); + + if (major && minor) { + return parseFloat(match[1] + '.' + match[2]); + } else if (major) { + return major; + } + + return null; + }(); + /** + * Whether or not this is a native Android browser. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_NATIVE_ANDROID = IS_ANDROID && ANDROID_VERSION < 5 && appleWebkitVersion < 537; + /** + * Whether or not this is Mozilla Firefox. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_FIREFOX = /Firefox/i.test(USER_AGENT); + /** + * Whether or not this is Microsoft Edge. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_EDGE = /Edge/i.test(USER_AGENT); + /** + * Whether or not this is Google Chrome. + * + * This will also be `true` for Chrome on iOS, which will have different support + * as it is actually Safari under the hood. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_CHROME = !IS_EDGE && (/Chrome/i.test(USER_AGENT) || /CriOS/i.test(USER_AGENT)); + /** + * The detected Google Chrome version - or `null`. + * + * @static + * @const + * @type {number|null} + */ + + var CHROME_VERSION = function () { + var match = USER_AGENT.match(/(Chrome|CriOS)\/(\d+)/); + + if (match && match[2]) { + return parseFloat(match[2]); + } + + return null; + }(); + /** + * The detected Internet Explorer version - or `null`. + * + * @static + * @const + * @type {number|null} + */ + + var IE_VERSION = function () { + var result = /MSIE\s(\d+)\.\d/.exec(USER_AGENT); + var version = result && parseFloat(result[1]); + + if (!version && /Trident\/7.0/i.test(USER_AGENT) && /rv:11.0/.test(USER_AGENT)) { + // IE 11 has a different user agent string than other IE versions + version = 11.0; + } + + return version; + }(); + /** + * Whether or not this is desktop Safari. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_SAFARI = /Safari/i.test(USER_AGENT) && !IS_CHROME && !IS_ANDROID && !IS_EDGE; + /** + * Whether or not this is a Windows machine. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_WINDOWS = /Windows/i.test(USER_AGENT); + /** + * Whether or not this device is touch-enabled. + * + * @static + * @const + * @type {Boolean} + */ + + var TOUCH_ENABLED = isReal() && ('ontouchstart' in window$1 || window$1.navigator.maxTouchPoints || window$1.DocumentTouch && window$1.document instanceof window$1.DocumentTouch); + /** + * Whether or not this device is an iPad. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_IPAD = /iPad/i.test(USER_AGENT) || IS_SAFARI && TOUCH_ENABLED && !/iPhone/i.test(USER_AGENT); + /** + * Whether or not this device is an iPhone. + * + * @static + * @const + * @type {Boolean} + */ + // The Facebook app's UIWebView identifies as both an iPhone and iPad, so + // to identify iPhones, we need to exclude iPads. + // http://artsy.github.io/blog/2012/10/18/the-perils-of-ios-user-agent-sniffing/ + + var IS_IPHONE = /iPhone/i.test(USER_AGENT) && !IS_IPAD; + /** + * Whether or not this is an iOS device. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_IOS = IS_IPHONE || IS_IPAD || IS_IPOD; + /** + * Whether or not this is any flavor of Safari - including iOS. + * + * @static + * @const + * @type {Boolean} + */ + + var IS_ANY_SAFARI = (IS_SAFARI || IS_IOS) && !IS_CHROME; + + var browser = /*#__PURE__*/Object.freeze({ + IS_IPOD: IS_IPOD, + IOS_VERSION: IOS_VERSION, + IS_ANDROID: IS_ANDROID, + ANDROID_VERSION: ANDROID_VERSION, + IS_NATIVE_ANDROID: IS_NATIVE_ANDROID, + IS_FIREFOX: IS_FIREFOX, + IS_EDGE: IS_EDGE, + IS_CHROME: IS_CHROME, + CHROME_VERSION: CHROME_VERSION, + IE_VERSION: IE_VERSION, + IS_SAFARI: IS_SAFARI, + IS_WINDOWS: IS_WINDOWS, + TOUCH_ENABLED: TOUCH_ENABLED, + IS_IPAD: IS_IPAD, + IS_IPHONE: IS_IPHONE, + IS_IOS: IS_IOS, + IS_ANY_SAFARI: IS_ANY_SAFARI + }); + + /** + * @file time-ranges.js + * @module time-ranges + */ + + /** + * Returns the time for the specified index at the start or end + * of a TimeRange object. + * + * @typedef {Function} TimeRangeIndex + * + * @param {number} [index=0] + * The range number to return the time for. + * + * @return {number} + * The time offset at the specified index. + * + * @deprecated The index argument must be provided. + * In the future, leaving it out will throw an error. + */ + + /** + * An object that contains ranges of time. + * + * @typedef {Object} TimeRange + * + * @property {number} length + * The number of time ranges represented by this object. + * + * @property {module:time-ranges~TimeRangeIndex} start + * Returns the time offset at which a specified time range begins. + * + * @property {module:time-ranges~TimeRangeIndex} end + * Returns the time offset at which a specified time range ends. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/TimeRanges + */ + + /** + * Check if any of the time ranges are over the maximum index. + * + * @private + * @param {string} fnName + * The function name to use for logging + * + * @param {number} index + * The index to check + * + * @param {number} maxIndex + * The maximum possible index + * + * @throws {Error} if the timeRanges provided are over the maxIndex + */ + function rangeCheck(fnName, index, maxIndex) { + if (typeof index !== 'number' || index < 0 || index > maxIndex) { + throw new Error("Failed to execute '" + fnName + "' on 'TimeRanges': The index provided (" + index + ") is non-numeric or out of bounds (0-" + maxIndex + ")."); + } + } + /** + * Get the time for the specified index at the start or end + * of a TimeRange object. + * + * @private + * @param {string} fnName + * The function name to use for logging + * + * @param {string} valueIndex + * The property that should be used to get the time. should be + * 'start' or 'end' + * + * @param {Array} ranges + * An array of time ranges + * + * @param {Array} [rangeIndex=0] + * The index to start the search at + * + * @return {number} + * The time that offset at the specified index. + * + * @deprecated rangeIndex must be set to a value, in the future this will throw an error. + * @throws {Error} if rangeIndex is more than the length of ranges + */ + + + function getRange(fnName, valueIndex, ranges, rangeIndex) { + rangeCheck(fnName, rangeIndex, ranges.length - 1); + return ranges[rangeIndex][valueIndex]; + } + /** + * Create a time range object given ranges of time. + * + * @private + * @param {Array} [ranges] + * An array of time ranges. + */ + + + function createTimeRangesObj(ranges) { + if (ranges === undefined || ranges.length === 0) { + return { + length: 0, + start: function start() { + throw new Error('This TimeRanges object is empty'); + }, + end: function end() { + throw new Error('This TimeRanges object is empty'); + } + }; + } + + return { + length: ranges.length, + start: getRange.bind(null, 'start', 0, ranges), + end: getRange.bind(null, 'end', 1, ranges) + }; + } + /** + * Create a `TimeRange` object which mimics an + * {@link https://developer.mozilla.org/en-US/docs/Web/API/TimeRanges|HTML5 TimeRanges instance}. + * + * @param {number|Array[]} start + * The start of a single range (a number) or an array of ranges (an + * array of arrays of two numbers each). + * + * @param {number} end + * The end of a single range. Cannot be used with the array form of + * the `start` argument. + */ + + + function createTimeRanges(start, end) { + if (Array.isArray(start)) { + return createTimeRangesObj(start); + } else if (start === undefined || end === undefined) { + return createTimeRangesObj(); + } + + return createTimeRangesObj([[start, end]]); + } + + /** + * @file buffer.js + * @module buffer + */ + /** + * Compute the percentage of the media that has been buffered. + * + * @param {TimeRange} buffered + * The current `TimeRange` object representing buffered time ranges + * + * @param {number} duration + * Total duration of the media + * + * @return {number} + * Percent buffered of the total duration in decimal form. + */ + + function bufferedPercent(buffered, duration) { + var bufferedDuration = 0; + var start; + var end; + + if (!duration) { + return 0; + } + + if (!buffered || !buffered.length) { + buffered = createTimeRanges(0, 0); + } + + for (var i = 0; i < buffered.length; i++) { + start = buffered.start(i); + end = buffered.end(i); // buffered end can be bigger than duration by a very small fraction + + if (end > duration) { + end = duration; + } + + bufferedDuration += end - start; + } + + return bufferedDuration / duration; + } + + /** + * @file fullscreen-api.js + * @module fullscreen-api + * @private + */ + /** + * Store the browser-specific methods for the fullscreen API. + * + * @type {Object} + * @see [Specification]{@link https://fullscreen.spec.whatwg.org} + * @see [Map Approach From Screenfull.js]{@link https://github.com/sindresorhus/screenfull.js} + */ + + var FullscreenApi = { + prefixed: true + }; // browser API methods + + var apiMap = [['requestFullscreen', 'exitFullscreen', 'fullscreenElement', 'fullscreenEnabled', 'fullscreenchange', 'fullscreenerror', 'fullscreen'], // WebKit + ['webkitRequestFullscreen', 'webkitExitFullscreen', 'webkitFullscreenElement', 'webkitFullscreenEnabled', 'webkitfullscreenchange', 'webkitfullscreenerror', '-webkit-full-screen'], // Mozilla + ['mozRequestFullScreen', 'mozCancelFullScreen', 'mozFullScreenElement', 'mozFullScreenEnabled', 'mozfullscreenchange', 'mozfullscreenerror', '-moz-full-screen'], // Microsoft + ['msRequestFullscreen', 'msExitFullscreen', 'msFullscreenElement', 'msFullscreenEnabled', 'MSFullscreenChange', 'MSFullscreenError', '-ms-fullscreen']]; + var specApi = apiMap[0]; + var browserApi; // determine the supported set of functions + + for (var i = 0; i < apiMap.length; i++) { + // check for exitFullscreen function + if (apiMap[i][1] in document) { + browserApi = apiMap[i]; + break; + } + } // map the browser API names to the spec API names + + + if (browserApi) { + for (var _i = 0; _i < browserApi.length; _i++) { + FullscreenApi[specApi[_i]] = browserApi[_i]; + } + + FullscreenApi.prefixed = browserApi[0] !== specApi[0]; + } + + /** + * @file media-error.js + */ + /** + * A Custom `MediaError` class which mimics the standard HTML5 `MediaError` class. + * + * @param {number|string|Object|MediaError} value + * This can be of multiple types: + * - number: should be a standard error code + * - string: an error message (the code will be 0) + * - Object: arbitrary properties + * - `MediaError` (native): used to populate a video.js `MediaError` object + * - `MediaError` (video.js): will return itself if it's already a + * video.js `MediaError` object. + * + * @see [MediaError Spec]{@link https://dev.w3.org/html5/spec-author-view/video.html#mediaerror} + * @see [Encrypted MediaError Spec]{@link https://www.w3.org/TR/2013/WD-encrypted-media-20130510/#error-codes} + * + * @class MediaError + */ + + function MediaError(value) { + // Allow redundant calls to this constructor to avoid having `instanceof` + // checks peppered around the code. + if (value instanceof MediaError) { + return value; + } + + if (typeof value === 'number') { + this.code = value; + } else if (typeof value === 'string') { + // default code is zero, so this is a custom error + this.message = value; + } else if (isObject(value)) { + // We assign the `code` property manually because native `MediaError` objects + // do not expose it as an own/enumerable property of the object. + if (typeof value.code === 'number') { + this.code = value.code; + } + + assign(this, value); + } + + if (!this.message) { + this.message = MediaError.defaultMessages[this.code] || ''; + } + } + /** + * The error code that refers two one of the defined `MediaError` types + * + * @type {Number} + */ + + + MediaError.prototype.code = 0; + /** + * An optional message that to show with the error. Message is not part of the HTML5 + * video spec but allows for more informative custom errors. + * + * @type {String} + */ + + MediaError.prototype.message = ''; + /** + * An optional status code that can be set by plugins to allow even more detail about + * the error. For example a plugin might provide a specific HTTP status code and an + * error message for that code. Then when the plugin gets that error this class will + * know how to display an error message for it. This allows a custom message to show + * up on the `Player` error overlay. + * + * @type {Array} + */ + + MediaError.prototype.status = null; + /** + * Errors indexed by the W3C standard. The order **CANNOT CHANGE**! See the + * specification listed under {@link MediaError} for more information. + * + * @enum {array} + * @readonly + * @property {string} 0 - MEDIA_ERR_CUSTOM + * @property {string} 1 - MEDIA_ERR_ABORTED + * @property {string} 2 - MEDIA_ERR_NETWORK + * @property {string} 3 - MEDIA_ERR_DECODE + * @property {string} 4 - MEDIA_ERR_SRC_NOT_SUPPORTED + * @property {string} 5 - MEDIA_ERR_ENCRYPTED + */ + + MediaError.errorTypes = ['MEDIA_ERR_CUSTOM', 'MEDIA_ERR_ABORTED', 'MEDIA_ERR_NETWORK', 'MEDIA_ERR_DECODE', 'MEDIA_ERR_SRC_NOT_SUPPORTED', 'MEDIA_ERR_ENCRYPTED']; + /** + * The default `MediaError` messages based on the {@link MediaError.errorTypes}. + * + * @type {Array} + * @constant + */ + + MediaError.defaultMessages = { + 1: 'You aborted the media playback', + 2: 'A network error caused the media download to fail part-way.', + 3: 'The media playback was aborted due to a corruption problem or because the media used features your browser did not support.', + 4: 'The media could not be loaded, either because the server or network failed or because the format is not supported.', + 5: 'The media is encrypted and we do not have the keys to decrypt it.' + }; // Add types as properties on MediaError + // e.g. MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED = 4; + + for (var errNum = 0; errNum < MediaError.errorTypes.length; errNum++) { + MediaError[MediaError.errorTypes[errNum]] = errNum; // values should be accessible on both the class and instance + + MediaError.prototype[MediaError.errorTypes[errNum]] = errNum; + } // jsdocs for instance/static members added above + + var tuple = SafeParseTuple; + + function SafeParseTuple(obj, reviver) { + var json; + var error = null; + + try { + json = JSON.parse(obj, reviver); + } catch (err) { + error = err; + } + + return [error, json]; + } + + /** + * Returns whether an object is `Promise`-like (i.e. has a `then` method). + * + * @param {Object} value + * An object that may or may not be `Promise`-like. + * + * @return {boolean} + * Whether or not the object is `Promise`-like. + */ + function isPromise(value) { + return value !== undefined && value !== null && typeof value.then === 'function'; + } + /** + * Silence a Promise-like object. + * + * This is useful for avoiding non-harmful, but potentially confusing "uncaught + * play promise" rejection error messages. + * + * @param {Object} value + * An object that may or may not be `Promise`-like. + */ + + function silencePromise(value) { + if (isPromise(value)) { + value.then(null, function (e) {}); + } + } + + /** + * @file text-track-list-converter.js Utilities for capturing text track state and + * re-creating tracks based on a capture. + * + * @module text-track-list-converter + */ + + /** + * Examine a single {@link TextTrack} and return a JSON-compatible javascript object that + * represents the {@link TextTrack}'s state. + * + * @param {TextTrack} track + * The text track to query. + * + * @return {Object} + * A serializable javascript representation of the TextTrack. + * @private + */ + var trackToJson_ = function trackToJson_(track) { + var ret = ['kind', 'label', 'language', 'id', 'inBandMetadataTrackDispatchType', 'mode', 'src'].reduce(function (acc, prop, i) { + if (track[prop]) { + acc[prop] = track[prop]; + } + + return acc; + }, { + cues: track.cues && Array.prototype.map.call(track.cues, function (cue) { + return { + startTime: cue.startTime, + endTime: cue.endTime, + text: cue.text, + id: cue.id + }; + }) + }); + return ret; + }; + /** + * Examine a {@link Tech} and return a JSON-compatible javascript array that represents the + * state of all {@link TextTrack}s currently configured. The return array is compatible with + * {@link text-track-list-converter:jsonToTextTracks}. + * + * @param {Tech} tech + * The tech object to query + * + * @return {Array} + * A serializable javascript representation of the {@link Tech}s + * {@link TextTrackList}. + */ + + + var textTracksToJson = function textTracksToJson(tech) { + var trackEls = tech.$$('track'); + var trackObjs = Array.prototype.map.call(trackEls, function (t) { + return t.track; + }); + var tracks = Array.prototype.map.call(trackEls, function (trackEl) { + var json = trackToJson_(trackEl.track); + + if (trackEl.src) { + json.src = trackEl.src; + } + + return json; + }); + return tracks.concat(Array.prototype.filter.call(tech.textTracks(), function (track) { + return trackObjs.indexOf(track) === -1; + }).map(trackToJson_)); + }; + /** + * Create a set of remote {@link TextTrack}s on a {@link Tech} based on an array of javascript + * object {@link TextTrack} representations. + * + * @param {Array} json + * An array of `TextTrack` representation objects, like those that would be + * produced by `textTracksToJson`. + * + * @param {Tech} tech + * The `Tech` to create the `TextTrack`s on. + */ + + + var jsonToTextTracks = function jsonToTextTracks(json, tech) { + json.forEach(function (track) { + var addedTrack = tech.addRemoteTextTrack(track).track; + + if (!track.src && track.cues) { + track.cues.forEach(function (cue) { + return addedTrack.addCue(cue); + }); + } + }); + return tech.textTracks(); + }; + + var textTrackConverter = { + textTracksToJson: textTracksToJson, + jsonToTextTracks: jsonToTextTracks, + trackToJson_: trackToJson_ + }; + + var keycode = createCommonjsModule(function (module, exports) { + // Source: http://jsfiddle.net/vWx8V/ + // http://stackoverflow.com/questions/5603195/full-list-of-javascript-keycodes + + /** + * Conenience method returns corresponding value for given keyName or keyCode. + * + * @param {Mixed} keyCode {Number} or keyName {String} + * @return {Mixed} + * @api public + */ + function keyCode(searchInput) { + // Keyboard Events + if (searchInput && 'object' === typeof searchInput) { + var hasKeyCode = searchInput.which || searchInput.keyCode || searchInput.charCode; + if (hasKeyCode) searchInput = hasKeyCode; + } // Numbers + + + if ('number' === typeof searchInput) return names[searchInput]; // Everything else (cast to string) + + var search = String(searchInput); // check codes + + var foundNamedKey = codes[search.toLowerCase()]; + if (foundNamedKey) return foundNamedKey; // check aliases + + var foundNamedKey = aliases[search.toLowerCase()]; + if (foundNamedKey) return foundNamedKey; // weird character? + + if (search.length === 1) return search.charCodeAt(0); + return undefined; + } + /** + * Compares a keyboard event with a given keyCode or keyName. + * + * @param {Event} event Keyboard event that should be tested + * @param {Mixed} keyCode {Number} or keyName {String} + * @return {Boolean} + * @api public + */ + + + keyCode.isEventKey = function isEventKey(event, nameOrCode) { + if (event && 'object' === typeof event) { + var keyCode = event.which || event.keyCode || event.charCode; + + if (keyCode === null || keyCode === undefined) { + return false; + } + + if (typeof nameOrCode === 'string') { + // check codes + var foundNamedKey = codes[nameOrCode.toLowerCase()]; + + if (foundNamedKey) { + return foundNamedKey === keyCode; + } // check aliases + + + var foundNamedKey = aliases[nameOrCode.toLowerCase()]; + + if (foundNamedKey) { + return foundNamedKey === keyCode; + } + } else if (typeof nameOrCode === 'number') { + return nameOrCode === keyCode; + } + + return false; + } + }; + + exports = module.exports = keyCode; + /** + * Get by name + * + * exports.code['enter'] // => 13 + */ + + var codes = exports.code = exports.codes = { + 'backspace': 8, + 'tab': 9, + 'enter': 13, + 'shift': 16, + 'ctrl': 17, + 'alt': 18, + 'pause/break': 19, + 'caps lock': 20, + 'esc': 27, + 'space': 32, + 'page up': 33, + 'page down': 34, + 'end': 35, + 'home': 36, + 'left': 37, + 'up': 38, + 'right': 39, + 'down': 40, + 'insert': 45, + 'delete': 46, + 'command': 91, + 'left command': 91, + 'right command': 93, + 'numpad *': 106, + 'numpad +': 107, + 'numpad -': 109, + 'numpad .': 110, + 'numpad /': 111, + 'num lock': 144, + 'scroll lock': 145, + 'my computer': 182, + 'my calculator': 183, + ';': 186, + '=': 187, + ',': 188, + '-': 189, + '.': 190, + '/': 191, + '`': 192, + '[': 219, + '\\': 220, + ']': 221, + "'": 222 // Helper aliases + + }; + var aliases = exports.aliases = { + 'windows': 91, + '⇧': 16, + '⌥': 18, + '⌃': 17, + '⌘': 91, + 'ctl': 17, + 'control': 17, + 'option': 18, + 'pause': 19, + 'break': 19, + 'caps': 20, + 'return': 13, + 'escape': 27, + 'spc': 32, + 'spacebar': 32, + 'pgup': 33, + 'pgdn': 34, + 'ins': 45, + 'del': 46, + 'cmd': 91 + /*! + * Programatically add the following + */ + // lower case chars + + }; + + for (i = 97; i < 123; i++) { + codes[String.fromCharCode(i)] = i - 32; + } // numbers + + + for (var i = 48; i < 58; i++) { + codes[i - 48] = i; + } // function keys + + + for (i = 1; i < 13; i++) { + codes['f' + i] = i + 111; + } // numpad keys + + + for (i = 0; i < 10; i++) { + codes['numpad ' + i] = i + 96; + } + /** + * Get by code + * + * exports.name[13] // => 'Enter' + */ + + + var names = exports.names = exports.title = {}; // title for backward compat + // Create reverse mapping + + for (i in codes) { + names[codes[i]] = i; + } // Add aliases + + + for (var alias in aliases) { + codes[alias] = aliases[alias]; + } + }); + var keycode_1 = keycode.code; + var keycode_2 = keycode.codes; + var keycode_3 = keycode.aliases; + var keycode_4 = keycode.names; + var keycode_5 = keycode.title; + + var MODAL_CLASS_NAME = 'vjs-modal-dialog'; + /** + * The `ModalDialog` displays over the video and its controls, which blocks + * interaction with the player until it is closed. + * + * Modal dialogs include a "Close" button and will close when that button + * is activated - or when ESC is pressed anywhere. + * + * @extends Component + */ + + var ModalDialog = + /*#__PURE__*/ + function (_Component) { + inheritsLoose(ModalDialog, _Component); + + /** + * Create an instance of this class. + * + * @param {Player} player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param {Mixed} [options.content=undefined] + * Provide customized content for this modal. + * + * @param {string} [options.description] + * A text description for the modal, primarily for accessibility. + * + * @param {boolean} [options.fillAlways=false] + * Normally, modals are automatically filled only the first time + * they open. This tells the modal to refresh its content + * every time it opens. + * + * @param {string} [options.label] + * A text label for the modal, primarily for accessibility. + * + * @param {boolean} [options.pauseOnOpen=true] + * If `true`, playback will will be paused if playing when + * the modal opens, and resumed when it closes. + * + * @param {boolean} [options.temporary=true] + * If `true`, the modal can only be opened once; it will be + * disposed as soon as it's closed. + * + * @param {boolean} [options.uncloseable=false] + * If `true`, the user will not be able to close the modal + * through the UI in the normal ways. Programmatic closing is + * still possible. + */ + function ModalDialog(player, options) { + var _this; + + _this = _Component.call(this, player, options) || this; + _this.opened_ = _this.hasBeenOpened_ = _this.hasBeenFilled_ = false; + + _this.closeable(!_this.options_.uncloseable); + + _this.content(_this.options_.content); // Make sure the contentEl is defined AFTER any children are initialized + // because we only want the contents of the modal in the contentEl + // (not the UI elements like the close button). + + + _this.contentEl_ = createEl('div', { + className: MODAL_CLASS_NAME + "-content" + }, { + role: 'document' + }); + _this.descEl_ = createEl('p', { + className: MODAL_CLASS_NAME + "-description vjs-control-text", + id: _this.el().getAttribute('aria-describedby') + }); + textContent(_this.descEl_, _this.description()); + + _this.el_.appendChild(_this.descEl_); + + _this.el_.appendChild(_this.contentEl_); + + return _this; + } + /** + * Create the `ModalDialog`'s DOM element + * + * @return {Element} + * The DOM element that gets created. + */ + + + var _proto = ModalDialog.prototype; + + _proto.createEl = function createEl() { + return _Component.prototype.createEl.call(this, 'div', { + className: this.buildCSSClass(), + tabIndex: -1 + }, { + 'aria-describedby': this.id() + "_description", + 'aria-hidden': 'true', + 'aria-label': this.label(), + 'role': 'dialog' + }); + }; + + _proto.dispose = function dispose() { + this.contentEl_ = null; + this.descEl_ = null; + this.previouslyActiveEl_ = null; + + _Component.prototype.dispose.call(this); + } + /** + * Builds the default DOM `className`. + * + * @return {string} + * The DOM `className` for this object. + */ + ; + + _proto.buildCSSClass = function buildCSSClass() { + return MODAL_CLASS_NAME + " vjs-hidden " + _Component.prototype.buildCSSClass.call(this); + } + /** + * Returns the label string for this modal. Primarily used for accessibility. + * + * @return {string} + * the localized or raw label of this modal. + */ + ; + + _proto.label = function label() { + return this.localize(this.options_.label || 'Modal Window'); + } + /** + * Returns the description string for this modal. Primarily used for + * accessibility. + * + * @return {string} + * The localized or raw description of this modal. + */ + ; + + _proto.description = function description() { + var desc = this.options_.description || this.localize('This is a modal window.'); // Append a universal closeability message if the modal is closeable. + + if (this.closeable()) { + desc += ' ' + this.localize('This modal can be closed by pressing the Escape key or activating the close button.'); + } + + return desc; + } + /** + * Opens the modal. + * + * @fires ModalDialog#beforemodalopen + * @fires ModalDialog#modalopen + */ + ; + + _proto.open = function open() { + if (!this.opened_) { + var player = this.player(); + /** + * Fired just before a `ModalDialog` is opened. + * + * @event ModalDialog#beforemodalopen + * @type {EventTarget~Event} + */ + + this.trigger('beforemodalopen'); + this.opened_ = true; // Fill content if the modal has never opened before and + // never been filled. + + if (this.options_.fillAlways || !this.hasBeenOpened_ && !this.hasBeenFilled_) { + this.fill(); + } // If the player was playing, pause it and take note of its previously + // playing state. + + + this.wasPlaying_ = !player.paused(); + + if (this.options_.pauseOnOpen && this.wasPlaying_) { + player.pause(); + } + + this.on('keydown', this.handleKeyDown); // Hide controls and note if they were enabled. + + this.hadControls_ = player.controls(); + player.controls(false); + this.show(); + this.conditionalFocus_(); + this.el().setAttribute('aria-hidden', 'false'); + /** + * Fired just after a `ModalDialog` is opened. + * + * @event ModalDialog#modalopen + * @type {EventTarget~Event} + */ + + this.trigger('modalopen'); + this.hasBeenOpened_ = true; + } + } + /** + * If the `ModalDialog` is currently open or closed. + * + * @param {boolean} [value] + * If given, it will open (`true`) or close (`false`) the modal. + * + * @return {boolean} + * the current open state of the modaldialog + */ + ; + + _proto.opened = function opened(value) { + if (typeof value === 'boolean') { + this[value ? 'open' : 'close'](); + } + + return this.opened_; + } + /** + * Closes the modal, does nothing if the `ModalDialog` is + * not open. + * + * @fires ModalDialog#beforemodalclose + * @fires ModalDialog#modalclose + */ + ; + + _proto.close = function close() { + if (!this.opened_) { + return; + } + + var player = this.player(); + /** + * Fired just before a `ModalDialog` is closed. + * + * @event ModalDialog#beforemodalclose + * @type {EventTarget~Event} + */ + + this.trigger('beforemodalclose'); + this.opened_ = false; + + if (this.wasPlaying_ && this.options_.pauseOnOpen) { + player.play(); + } + + this.off('keydown', this.handleKeyDown); + + if (this.hadControls_) { + player.controls(true); + } + + this.hide(); + this.el().setAttribute('aria-hidden', 'true'); + /** + * Fired just after a `ModalDialog` is closed. + * + * @event ModalDialog#modalclose + * @type {EventTarget~Event} + */ + + this.trigger('modalclose'); + this.conditionalBlur_(); + + if (this.options_.temporary) { + this.dispose(); + } + } + /** + * Check to see if the `ModalDialog` is closeable via the UI. + * + * @param {boolean} [value] + * If given as a boolean, it will set the `closeable` option. + * + * @return {boolean} + * Returns the final value of the closable option. + */ + ; + + _proto.closeable = function closeable(value) { + if (typeof value === 'boolean') { + var closeable = this.closeable_ = !!value; + var close = this.getChild('closeButton'); // If this is being made closeable and has no close button, add one. + + if (closeable && !close) { + // The close button should be a child of the modal - not its + // content element, so temporarily change the content element. + var temp = this.contentEl_; + this.contentEl_ = this.el_; + close = this.addChild('closeButton', { + controlText: 'Close Modal Dialog' + }); + this.contentEl_ = temp; + this.on(close, 'close', this.close); + } // If this is being made uncloseable and has a close button, remove it. + + + if (!closeable && close) { + this.off(close, 'close', this.close); + this.removeChild(close); + close.dispose(); + } + } + + return this.closeable_; + } + /** + * Fill the modal's content element with the modal's "content" option. + * The content element will be emptied before this change takes place. + */ + ; + + _proto.fill = function fill() { + this.fillWith(this.content()); + } + /** + * Fill the modal's content element with arbitrary content. + * The content element will be emptied before this change takes place. + * + * @fires ModalDialog#beforemodalfill + * @fires ModalDialog#modalfill + * + * @param {Mixed} [content] + * The same rules apply to this as apply to the `content` option. + */ + ; + + _proto.fillWith = function fillWith(content) { + var contentEl = this.contentEl(); + var parentEl = contentEl.parentNode; + var nextSiblingEl = contentEl.nextSibling; + /** + * Fired just before a `ModalDialog` is filled with content. + * + * @event ModalDialog#beforemodalfill + * @type {EventTarget~Event} + */ + + this.trigger('beforemodalfill'); + this.hasBeenFilled_ = true; // Detach the content element from the DOM before performing + // manipulation to avoid modifying the live DOM multiple times. + + parentEl.removeChild(contentEl); + this.empty(); + insertContent(contentEl, content); + /** + * Fired just after a `ModalDialog` is filled with content. + * + * @event ModalDialog#modalfill + * @type {EventTarget~Event} + */ + + this.trigger('modalfill'); // Re-inject the re-filled content element. + + if (nextSiblingEl) { + parentEl.insertBefore(contentEl, nextSiblingEl); + } else { + parentEl.appendChild(contentEl); + } // make sure that the close button is last in the dialog DOM + + + var closeButton = this.getChild('closeButton'); + + if (closeButton) { + parentEl.appendChild(closeButton.el_); + } + } + /** + * Empties the content element. This happens anytime the modal is filled. + * + * @fires ModalDialog#beforemodalempty + * @fires ModalDialog#modalempty + */ + ; + + _proto.empty = function empty() { + /** + * Fired just before a `ModalDialog` is emptied. + * + * @event ModalDialog#beforemodalempty + * @type {EventTarget~Event} + */ + this.trigger('beforemodalempty'); + emptyEl(this.contentEl()); + /** + * Fired just after a `ModalDialog` is emptied. + * + * @event ModalDialog#modalempty + * @type {EventTarget~Event} + */ + + this.trigger('modalempty'); + } + /** + * Gets or sets the modal content, which gets normalized before being + * rendered into the DOM. + * + * This does not update the DOM or fill the modal, but it is called during + * that process. + * + * @param {Mixed} [value] + * If defined, sets the internal content value to be used on the + * next call(s) to `fill`. This value is normalized before being + * inserted. To "clear" the internal content value, pass `null`. + * + * @return {Mixed} + * The current content of the modal dialog + */ + ; + + _proto.content = function content(value) { + if (typeof value !== 'undefined') { + this.content_ = value; + } + + return this.content_; + } + /** + * conditionally focus the modal dialog if focus was previously on the player. + * + * @private + */ + ; + + _proto.conditionalFocus_ = function conditionalFocus_() { + var activeEl = document.activeElement; + var playerEl = this.player_.el_; + this.previouslyActiveEl_ = null; + + if (playerEl.contains(activeEl) || playerEl === activeEl) { + this.previouslyActiveEl_ = activeEl; + this.focus(); + } + } + /** + * conditionally blur the element and refocus the last focused element + * + * @private + */ + ; + + _proto.conditionalBlur_ = function conditionalBlur_() { + if (this.previouslyActiveEl_) { + this.previouslyActiveEl_.focus(); + this.previouslyActiveEl_ = null; + } + } + /** + * Keydown handler. Attached when modal is focused. + * + * @listens keydown + */ + ; + + _proto.handleKeyDown = function handleKeyDown(event) { + // Do not allow keydowns to reach out of the modal dialog. + event.stopPropagation(); + + if (keycode.isEventKey(event, 'Escape') && this.closeable()) { + event.preventDefault(); + this.close(); + return; + } // exit early if it isn't a tab key + + + if (!keycode.isEventKey(event, 'Tab')) { + return; + } + + var focusableEls = this.focusableEls_(); + var activeEl = this.el_.querySelector(':focus'); + var focusIndex; + + for (var i = 0; i < focusableEls.length; i++) { + if (activeEl === focusableEls[i]) { + focusIndex = i; + break; + } + } + + if (document.activeElement === this.el_) { + focusIndex = 0; + } + + if (event.shiftKey && focusIndex === 0) { + focusableEls[focusableEls.length - 1].focus(); + event.preventDefault(); + } else if (!event.shiftKey && focusIndex === focusableEls.length - 1) { + focusableEls[0].focus(); + event.preventDefault(); + } + } + /** + * get all focusable elements + * + * @private + */ + ; + + _proto.focusableEls_ = function focusableEls_() { + var allChildren = this.el_.querySelectorAll('*'); + return Array.prototype.filter.call(allChildren, function (child) { + return (child instanceof window$1.HTMLAnchorElement || child instanceof window$1.HTMLAreaElement) && child.hasAttribute('href') || (child instanceof window$1.HTMLInputElement || child instanceof window$1.HTMLSelectElement || child instanceof window$1.HTMLTextAreaElement || child instanceof window$1.HTMLButtonElement) && !child.hasAttribute('disabled') || child instanceof window$1.HTMLIFrameElement || child instanceof window$1.HTMLObjectElement || child instanceof window$1.HTMLEmbedElement || child.hasAttribute('tabindex') && child.getAttribute('tabindex') !== -1 || child.hasAttribute('contenteditable'); + }); + }; + + return ModalDialog; + }(Component); + /** + * Default options for `ModalDialog` default options. + * + * @type {Object} + * @private + */ + + + ModalDialog.prototype.options_ = { + pauseOnOpen: true, + temporary: true + }; + Component.registerComponent('ModalDialog', ModalDialog); + + /** + * Common functionaliy between {@link TextTrackList}, {@link AudioTrackList}, and + * {@link VideoTrackList} + * + * @extends EventTarget + */ + + var TrackList = + /*#__PURE__*/ + function (_EventTarget) { + inheritsLoose(TrackList, _EventTarget); + + /** + * Create an instance of this class + * + * @param {Track[]} tracks + * A list of tracks to initialize the list with. + * + * @abstract + */ + function TrackList(tracks) { + var _this; + + if (tracks === void 0) { + tracks = []; + } + + _this = _EventTarget.call(this) || this; + _this.tracks_ = []; + /** + * @memberof TrackList + * @member {number} length + * The current number of `Track`s in the this Trackist. + * @instance + */ + + Object.defineProperty(assertThisInitialized(_this), 'length', { + get: function get() { + return this.tracks_.length; + } + }); + + for (var i = 0; i < tracks.length; i++) { + _this.addTrack(tracks[i]); + } + + return _this; + } + /** + * Add a {@link Track} to the `TrackList` + * + * @param {Track} track + * The audio, video, or text track to add to the list. + * + * @fires TrackList#addtrack + */ + + + var _proto = TrackList.prototype; + + _proto.addTrack = function addTrack(track) { + var index = this.tracks_.length; + + if (!('' + index in this)) { + Object.defineProperty(this, index, { + get: function get() { + return this.tracks_[index]; + } + }); + } // Do not add duplicate tracks + + + if (this.tracks_.indexOf(track) === -1) { + this.tracks_.push(track); + /** + * Triggered when a track is added to a track list. + * + * @event TrackList#addtrack + * @type {EventTarget~Event} + * @property {Track} track + * A reference to track that was added. + */ + + this.trigger({ + track: track, + type: 'addtrack', + target: this + }); + } + } + /** + * Remove a {@link Track} from the `TrackList` + * + * @param {Track} rtrack + * The audio, video, or text track to remove from the list. + * + * @fires TrackList#removetrack + */ + ; + + _proto.removeTrack = function removeTrack(rtrack) { + var track; + + for (var i = 0, l = this.length; i < l; i++) { + if (this[i] === rtrack) { + track = this[i]; + + if (track.off) { + track.off(); + } + + this.tracks_.splice(i, 1); + break; + } + } + + if (!track) { + return; + } + /** + * Triggered when a track is removed from track list. + * + * @event TrackList#removetrack + * @type {EventTarget~Event} + * @property {Track} track + * A reference to track that was removed. + */ + + + this.trigger({ + track: track, + type: 'removetrack', + target: this + }); + } + /** + * Get a Track from the TrackList by a tracks id + * + * @param {string} id - the id of the track to get + * @method getTrackById + * @return {Track} + * @private + */ + ; + + _proto.getTrackById = function getTrackById(id) { + var result = null; + + for (var i = 0, l = this.length; i < l; i++) { + var track = this[i]; + + if (track.id === id) { + result = track; + break; + } + } + + return result; + }; + + return TrackList; + }(EventTarget); + /** + * Triggered when a different track is selected/enabled. + * + * @event TrackList#change + * @type {EventTarget~Event} + */ + + /** + * Events that can be called with on + eventName. See {@link EventHandler}. + * + * @property {Object} TrackList#allowedEvents_ + * @private + */ + + + TrackList.prototype.allowedEvents_ = { + change: 'change', + addtrack: 'addtrack', + removetrack: 'removetrack' + }; // emulate attribute EventHandler support to allow for feature detection + + for (var event in TrackList.prototype.allowedEvents_) { + TrackList.prototype['on' + event] = null; + } + + /** + * Anywhere we call this function we diverge from the spec + * as we only support one enabled audiotrack at a time + * + * @param {AudioTrackList} list + * list to work on + * + * @param {AudioTrack} track + * The track to skip + * + * @private + */ + + var disableOthers = function disableOthers(list, track) { + for (var i = 0; i < list.length; i++) { + if (!Object.keys(list[i]).length || track.id === list[i].id) { + continue; + } // another audio track is enabled, disable it + + + list[i].enabled = false; + } + }; + /** + * The current list of {@link AudioTrack} for a media file. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#audiotracklist} + * @extends TrackList + */ + + + var AudioTrackList = + /*#__PURE__*/ + function (_TrackList) { + inheritsLoose(AudioTrackList, _TrackList); + + /** + * Create an instance of this class. + * + * @param {AudioTrack[]} [tracks=[]] + * A list of `AudioTrack` to instantiate the list with. + */ + function AudioTrackList(tracks) { + var _this; + + if (tracks === void 0) { + tracks = []; + } + + // make sure only 1 track is enabled + // sorted from last index to first index + for (var i = tracks.length - 1; i >= 0; i--) { + if (tracks[i].enabled) { + disableOthers(tracks, tracks[i]); + break; + } + } + + _this = _TrackList.call(this, tracks) || this; + _this.changing_ = false; + return _this; + } + /** + * Add an {@link AudioTrack} to the `AudioTrackList`. + * + * @param {AudioTrack} track + * The AudioTrack to add to the list + * + * @fires TrackList#addtrack + */ + + + var _proto = AudioTrackList.prototype; + + _proto.addTrack = function addTrack(track) { + var _this2 = this; + + if (track.enabled) { + disableOthers(this, track); + } + + _TrackList.prototype.addTrack.call(this, track); // native tracks don't have this + + + if (!track.addEventListener) { + return; + } + + track.enabledChange_ = function () { + // when we are disabling other tracks (since we don't support + // more than one track at a time) we will set changing_ + // to true so that we don't trigger additional change events + if (_this2.changing_) { + return; + } + + _this2.changing_ = true; + disableOthers(_this2, track); + _this2.changing_ = false; + + _this2.trigger('change'); + }; + /** + * @listens AudioTrack#enabledchange + * @fires TrackList#change + */ + + + track.addEventListener('enabledchange', track.enabledChange_); + }; + + _proto.removeTrack = function removeTrack(rtrack) { + _TrackList.prototype.removeTrack.call(this, rtrack); + + if (rtrack.removeEventListener && rtrack.enabledChange_) { + rtrack.removeEventListener('enabledchange', rtrack.enabledChange_); + rtrack.enabledChange_ = null; + } + }; + + return AudioTrackList; + }(TrackList); + + /** + * Un-select all other {@link VideoTrack}s that are selected. + * + * @param {VideoTrackList} list + * list to work on + * + * @param {VideoTrack} track + * The track to skip + * + * @private + */ + + var disableOthers$1 = function disableOthers(list, track) { + for (var i = 0; i < list.length; i++) { + if (!Object.keys(list[i]).length || track.id === list[i].id) { + continue; + } // another video track is enabled, disable it + + + list[i].selected = false; + } + }; + /** + * The current list of {@link VideoTrack} for a video. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#videotracklist} + * @extends TrackList + */ + + + var VideoTrackList = + /*#__PURE__*/ + function (_TrackList) { + inheritsLoose(VideoTrackList, _TrackList); + + /** + * Create an instance of this class. + * + * @param {VideoTrack[]} [tracks=[]] + * A list of `VideoTrack` to instantiate the list with. + */ + function VideoTrackList(tracks) { + var _this; + + if (tracks === void 0) { + tracks = []; + } + + // make sure only 1 track is enabled + // sorted from last index to first index + for (var i = tracks.length - 1; i >= 0; i--) { + if (tracks[i].selected) { + disableOthers$1(tracks, tracks[i]); + break; + } + } + + _this = _TrackList.call(this, tracks) || this; + _this.changing_ = false; + /** + * @member {number} VideoTrackList#selectedIndex + * The current index of the selected {@link VideoTrack`}. + */ + + Object.defineProperty(assertThisInitialized(_this), 'selectedIndex', { + get: function get() { + for (var _i = 0; _i < this.length; _i++) { + if (this[_i].selected) { + return _i; + } + } + + return -1; + }, + set: function set() {} + }); + return _this; + } + /** + * Add a {@link VideoTrack} to the `VideoTrackList`. + * + * @param {VideoTrack} track + * The VideoTrack to add to the list + * + * @fires TrackList#addtrack + */ + + + var _proto = VideoTrackList.prototype; + + _proto.addTrack = function addTrack(track) { + var _this2 = this; + + if (track.selected) { + disableOthers$1(this, track); + } + + _TrackList.prototype.addTrack.call(this, track); // native tracks don't have this + + + if (!track.addEventListener) { + return; + } + + track.selectedChange_ = function () { + if (_this2.changing_) { + return; + } + + _this2.changing_ = true; + disableOthers$1(_this2, track); + _this2.changing_ = false; + + _this2.trigger('change'); + }; + /** + * @listens VideoTrack#selectedchange + * @fires TrackList#change + */ + + + track.addEventListener('selectedchange', track.selectedChange_); + }; + + _proto.removeTrack = function removeTrack(rtrack) { + _TrackList.prototype.removeTrack.call(this, rtrack); + + if (rtrack.removeEventListener && rtrack.selectedChange_) { + rtrack.removeEventListener('selectedchange', rtrack.selectedChange_); + rtrack.selectedChange_ = null; + } + }; + + return VideoTrackList; + }(TrackList); + + /** + * The current list of {@link TextTrack} for a media file. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#texttracklist} + * @extends TrackList + */ + + var TextTrackList = + /*#__PURE__*/ + function (_TrackList) { + inheritsLoose(TextTrackList, _TrackList); + + function TextTrackList() { + return _TrackList.apply(this, arguments) || this; + } + + var _proto = TextTrackList.prototype; + + /** + * Add a {@link TextTrack} to the `TextTrackList` + * + * @param {TextTrack} track + * The text track to add to the list. + * + * @fires TrackList#addtrack + */ + _proto.addTrack = function addTrack(track) { + var _this = this; + + _TrackList.prototype.addTrack.call(this, track); + + if (!this.queueChange_) { + this.queueChange_ = function () { + return _this.queueTrigger('change'); + }; + } + + if (!this.triggerSelectedlanguagechange) { + this.triggerSelectedlanguagechange_ = function () { + return _this.trigger('selectedlanguagechange'); + }; + } + /** + * @listens TextTrack#modechange + * @fires TrackList#change + */ + + + track.addEventListener('modechange', this.queueChange_); + var nonLanguageTextTrackKind = ['metadata', 'chapters']; + + if (nonLanguageTextTrackKind.indexOf(track.kind) === -1) { + track.addEventListener('modechange', this.triggerSelectedlanguagechange_); + } + }; + + _proto.removeTrack = function removeTrack(rtrack) { + _TrackList.prototype.removeTrack.call(this, rtrack); // manually remove the event handlers we added + + + if (rtrack.removeEventListener) { + if (this.queueChange_) { + rtrack.removeEventListener('modechange', this.queueChange_); + } + + if (this.selectedlanguagechange_) { + rtrack.removeEventListener('modechange', this.triggerSelectedlanguagechange_); + } + } + }; + + return TextTrackList; + }(TrackList); + + /** + * @file html-track-element-list.js + */ + + /** + * The current list of {@link HtmlTrackElement}s. + */ + var HtmlTrackElementList = + /*#__PURE__*/ + function () { + /** + * Create an instance of this class. + * + * @param {HtmlTrackElement[]} [tracks=[]] + * A list of `HtmlTrackElement` to instantiate the list with. + */ + function HtmlTrackElementList(trackElements) { + if (trackElements === void 0) { + trackElements = []; + } + + this.trackElements_ = []; + /** + * @memberof HtmlTrackElementList + * @member {number} length + * The current number of `Track`s in the this Trackist. + * @instance + */ + + Object.defineProperty(this, 'length', { + get: function get() { + return this.trackElements_.length; + } + }); + + for (var i = 0, length = trackElements.length; i < length; i++) { + this.addTrackElement_(trackElements[i]); + } + } + /** + * Add an {@link HtmlTrackElement} to the `HtmlTrackElementList` + * + * @param {HtmlTrackElement} trackElement + * The track element to add to the list. + * + * @private + */ + + + var _proto = HtmlTrackElementList.prototype; + + _proto.addTrackElement_ = function addTrackElement_(trackElement) { + var index = this.trackElements_.length; + + if (!('' + index in this)) { + Object.defineProperty(this, index, { + get: function get() { + return this.trackElements_[index]; + } + }); + } // Do not add duplicate elements + + + if (this.trackElements_.indexOf(trackElement) === -1) { + this.trackElements_.push(trackElement); + } + } + /** + * Get an {@link HtmlTrackElement} from the `HtmlTrackElementList` given an + * {@link TextTrack}. + * + * @param {TextTrack} track + * The track associated with a track element. + * + * @return {HtmlTrackElement|undefined} + * The track element that was found or undefined. + * + * @private + */ + ; + + _proto.getTrackElementByTrack_ = function getTrackElementByTrack_(track) { + var trackElement_; + + for (var i = 0, length = this.trackElements_.length; i < length; i++) { + if (track === this.trackElements_[i].track) { + trackElement_ = this.trackElements_[i]; + break; + } + } + + return trackElement_; + } + /** + * Remove a {@link HtmlTrackElement} from the `HtmlTrackElementList` + * + * @param {HtmlTrackElement} trackElement + * The track element to remove from the list. + * + * @private + */ + ; + + _proto.removeTrackElement_ = function removeTrackElement_(trackElement) { + for (var i = 0, length = this.trackElements_.length; i < length; i++) { + if (trackElement === this.trackElements_[i]) { + if (this.trackElements_[i].track && typeof this.trackElements_[i].track.off === 'function') { + this.trackElements_[i].track.off(); + } + + if (typeof this.trackElements_[i].off === 'function') { + this.trackElements_[i].off(); + } + + this.trackElements_.splice(i, 1); + break; + } + } + }; + + return HtmlTrackElementList; + }(); + + /** + * @file text-track-cue-list.js + */ + + /** + * @typedef {Object} TextTrackCueList~TextTrackCue + * + * @property {string} id + * The unique id for this text track cue + * + * @property {number} startTime + * The start time for this text track cue + * + * @property {number} endTime + * The end time for this text track cue + * + * @property {boolean} pauseOnExit + * Pause when the end time is reached if true. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#texttrackcue} + */ + + /** + * A List of TextTrackCues. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#texttrackcuelist} + */ + var TextTrackCueList = + /*#__PURE__*/ + function () { + /** + * Create an instance of this class.. + * + * @param {Array} cues + * A list of cues to be initialized with + */ + function TextTrackCueList(cues) { + TextTrackCueList.prototype.setCues_.call(this, cues); + /** + * @memberof TextTrackCueList + * @member {number} length + * The current number of `TextTrackCue`s in the TextTrackCueList. + * @instance + */ + + Object.defineProperty(this, 'length', { + get: function get() { + return this.length_; + } + }); + } + /** + * A setter for cues in this list. Creates getters + * an an index for the cues. + * + * @param {Array} cues + * An array of cues to set + * + * @private + */ + + + var _proto = TextTrackCueList.prototype; + + _proto.setCues_ = function setCues_(cues) { + var oldLength = this.length || 0; + var i = 0; + var l = cues.length; + this.cues_ = cues; + this.length_ = cues.length; + + var defineProp = function defineProp(index) { + if (!('' + index in this)) { + Object.defineProperty(this, '' + index, { + get: function get() { + return this.cues_[index]; + } + }); + } + }; + + if (oldLength < l) { + i = oldLength; + + for (; i < l; i++) { + defineProp.call(this, i); + } + } + } + /** + * Get a `TextTrackCue` that is currently in the `TextTrackCueList` by id. + * + * @param {string} id + * The id of the cue that should be searched for. + * + * @return {TextTrackCueList~TextTrackCue|null} + * A single cue or null if none was found. + */ + ; + + _proto.getCueById = function getCueById(id) { + var result = null; + + for (var i = 0, l = this.length; i < l; i++) { + var cue = this[i]; + + if (cue.id === id) { + result = cue; + break; + } + } + + return result; + }; + + return TextTrackCueList; + }(); + + /** + * @file track-kinds.js + */ + + /** + * All possible `VideoTrackKind`s + * + * @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-videotrack-kind + * @typedef VideoTrack~Kind + * @enum + */ + var VideoTrackKind = { + alternative: 'alternative', + captions: 'captions', + main: 'main', + sign: 'sign', + subtitles: 'subtitles', + commentary: 'commentary' + }; + /** + * All possible `AudioTrackKind`s + * + * @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-audiotrack-kind + * @typedef AudioTrack~Kind + * @enum + */ + + var AudioTrackKind = { + 'alternative': 'alternative', + 'descriptions': 'descriptions', + 'main': 'main', + 'main-desc': 'main-desc', + 'translation': 'translation', + 'commentary': 'commentary' + }; + /** + * All possible `TextTrackKind`s + * + * @see https://html.spec.whatwg.org/multipage/embedded-content.html#dom-texttrack-kind + * @typedef TextTrack~Kind + * @enum + */ + + var TextTrackKind = { + subtitles: 'subtitles', + captions: 'captions', + descriptions: 'descriptions', + chapters: 'chapters', + metadata: 'metadata' + }; + /** + * All possible `TextTrackMode`s + * + * @see https://html.spec.whatwg.org/multipage/embedded-content.html#texttrackmode + * @typedef TextTrack~Mode + * @enum + */ + + var TextTrackMode = { + disabled: 'disabled', + hidden: 'hidden', + showing: 'showing' + }; + + /** + * A Track class that contains all of the common functionality for {@link AudioTrack}, + * {@link VideoTrack}, and {@link TextTrack}. + * + * > Note: This class should not be used directly + * + * @see {@link https://html.spec.whatwg.org/multipage/embedded-content.html} + * @extends EventTarget + * @abstract + */ + + var Track = + /*#__PURE__*/ + function (_EventTarget) { + inheritsLoose(Track, _EventTarget); + + /** + * Create an instance of this class. + * + * @param {Object} [options={}] + * Object of option names and values + * + * @param {string} [options.kind=''] + * A valid kind for the track type you are creating. + * + * @param {string} [options.id='vjs_track_' + Guid.newGUID()] + * A unique id for this AudioTrack. + * + * @param {string} [options.label=''] + * The menu label for this track. + * + * @param {string} [options.language=''] + * A valid two character language code. + * + * @abstract + */ + function Track(options) { + var _this; + + if (options === void 0) { + options = {}; + } + + _this = _EventTarget.call(this) || this; + var trackProps = { + id: options.id || 'vjs_track_' + newGUID(), + kind: options.kind || '', + label: options.label || '', + language: options.language || '' + }; + /** + * @memberof Track + * @member {string} id + * The id of this track. Cannot be changed after creation. + * @instance + * + * @readonly + */ + + /** + * @memberof Track + * @member {string} kind + * The kind of track that this is. Cannot be changed after creation. + * @instance + * + * @readonly + */ + + /** + * @memberof Track + * @member {string} label + * The label of this track. Cannot be changed after creation. + * @instance + * + * @readonly + */ + + /** + * @memberof Track + * @member {string} language + * The two letter language code for this track. Cannot be changed after + * creation. + * @instance + * + * @readonly + */ + + var _loop = function _loop(key) { + Object.defineProperty(assertThisInitialized(_this), key, { + get: function get() { + return trackProps[key]; + }, + set: function set() {} + }); + }; + + for (var key in trackProps) { + _loop(key); + } + + return _this; + } + + return Track; + }(EventTarget); + + /** + * @file url.js + * @module url + */ + /** + * @typedef {Object} url:URLObject + * + * @property {string} protocol + * The protocol of the url that was parsed. + * + * @property {string} hostname + * The hostname of the url that was parsed. + * + * @property {string} port + * The port of the url that was parsed. + * + * @property {string} pathname + * The pathname of the url that was parsed. + * + * @property {string} search + * The search query of the url that was parsed. + * + * @property {string} hash + * The hash of the url that was parsed. + * + * @property {string} host + * The host of the url that was parsed. + */ + + /** + * Resolve and parse the elements of a URL. + * + * @function + * @param {String} url + * The url to parse + * + * @return {url:URLObject} + * An object of url details + */ + + var parseUrl = function parseUrl(url) { + var props = ['protocol', 'hostname', 'port', 'pathname', 'search', 'hash', 'host']; // add the url to an anchor and let the browser parse the URL + + var a = document.createElement('a'); + a.href = url; // IE8 (and 9?) Fix + // ie8 doesn't parse the URL correctly until the anchor is actually + // added to the body, and an innerHTML is needed to trigger the parsing + + var addToBody = a.host === '' && a.protocol !== 'file:'; + var div; + + if (addToBody) { + div = document.createElement('div'); + div.innerHTML = ""; + a = div.firstChild; // prevent the div from affecting layout + + div.setAttribute('style', 'display:none; position:absolute;'); + document.body.appendChild(div); + } // Copy the specific URL properties to a new object + // This is also needed for IE8 because the anchor loses its + // properties when it's removed from the dom + + + var details = {}; + + for (var i = 0; i < props.length; i++) { + details[props[i]] = a[props[i]]; + } // IE9 adds the port to the host property unlike everyone else. If + // a port identifier is added for standard ports, strip it. + + + if (details.protocol === 'http:') { + details.host = details.host.replace(/:80$/, ''); + } + + if (details.protocol === 'https:') { + details.host = details.host.replace(/:443$/, ''); + } + + if (!details.protocol) { + details.protocol = window$1.location.protocol; + } + + if (addToBody) { + document.body.removeChild(div); + } + + return details; + }; + /** + * Get absolute version of relative URL. Used to tell Flash the correct URL. + * + * @function + * @param {string} url + * URL to make absolute + * + * @return {string} + * Absolute URL + * + * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue + */ + + var getAbsoluteURL = function getAbsoluteURL(url) { + // Check if absolute URL + if (!url.match(/^https?:\/\//)) { + // Convert to absolute URL. Flash hosted off-site needs an absolute URL. + var div = document.createElement('div'); + div.innerHTML = "x"; + url = div.firstChild.href; + } + + return url; + }; + /** + * Returns the extension of the passed file name. It will return an empty string + * if passed an invalid path. + * + * @function + * @param {string} path + * The fileName path like '/path/to/file.mp4' + * + * @return {string} + * The extension in lower case or an empty string if no + * extension could be found. + */ + + var getFileExtension = function getFileExtension(path) { + if (typeof path === 'string') { + var splitPathRe = /^(\/?)([\s\S]*?)((?:\.{1,2}|[^\/]+?)(\.([^\.\/\?]+)))(?:[\/]*|[\?].*)$/; + var pathParts = splitPathRe.exec(path); + + if (pathParts) { + return pathParts.pop().toLowerCase(); + } + } + + return ''; + }; + /** + * Returns whether the url passed is a cross domain request or not. + * + * @function + * @param {string} url + * The url to check. + * + * @param {Object} [winLoc] + * the domain to check the url against, defaults to window.location + * + * @param {string} [winLoc.protocol] + * The window location protocol defaults to window.location.protocol + * + * @param {string} [winLoc.host] + * The window location host defaults to window.location.host + * + * @return {boolean} + * Whether it is a cross domain request or not. + */ + + var isCrossOrigin = function isCrossOrigin(url, winLoc) { + if (winLoc === void 0) { + winLoc = window$1.location; + } + + var urlInfo = parseUrl(url); // IE8 protocol relative urls will return ':' for protocol + + var srcProtocol = urlInfo.protocol === ':' ? winLoc.protocol : urlInfo.protocol; // Check if url is for another domain/origin + // IE8 doesn't know location.origin, so we won't rely on it here + + var crossOrigin = srcProtocol + urlInfo.host !== winLoc.protocol + winLoc.host; + return crossOrigin; + }; + + var Url = /*#__PURE__*/Object.freeze({ + parseUrl: parseUrl, + getAbsoluteURL: getAbsoluteURL, + getFileExtension: getFileExtension, + isCrossOrigin: isCrossOrigin + }); + + var isFunction_1 = isFunction; + var toString$1 = Object.prototype.toString; + + function isFunction(fn) { + var string = toString$1.call(fn); + return string === '[object Function]' || typeof fn === 'function' && string !== '[object RegExp]' || typeof window !== 'undefined' && ( // IE8 and below + fn === window.setTimeout || fn === window.alert || fn === window.confirm || fn === window.prompt); + } + + /** + * @license + * slighly modified parse-headers 2.0.2 + * Copyright (c) 2014 David Björklund + * Available under the MIT license + * + */ + + + var parseHeaders = function parseHeaders(headers) { + var result = {}; + + if (!headers) { + return result; + } + + headers.trim().split('\n').forEach(function (row) { + var index = row.indexOf(':'); + var key = row.slice(0, index).trim().toLowerCase(); + var value = row.slice(index + 1).trim(); + + if (typeof result[key] === 'undefined') { + result[key] = value; + } else if (Array.isArray(result[key])) { + result[key].push(value); + } else { + result[key] = [result[key], value]; + } + }); + return result; + }; + + var xhr = createXHR; // Allow use of default import syntax in TypeScript + + var default_1 = createXHR; + createXHR.XMLHttpRequest = window$1.XMLHttpRequest || noop; + createXHR.XDomainRequest = "withCredentials" in new createXHR.XMLHttpRequest() ? createXHR.XMLHttpRequest : window$1.XDomainRequest; + forEachArray(["get", "put", "post", "patch", "head", "delete"], function (method) { + createXHR[method === "delete" ? "del" : method] = function (uri, options, callback) { + options = initParams(uri, options, callback); + options.method = method.toUpperCase(); + return _createXHR(options); + }; + }); + + function forEachArray(array, iterator) { + for (var i = 0; i < array.length; i++) { + iterator(array[i]); + } + } + + function isEmpty(obj) { + for (var i in obj) { + if (obj.hasOwnProperty(i)) return false; + } + + return true; + } + + function initParams(uri, options, callback) { + var params = uri; + + if (isFunction_1(options)) { + callback = options; + + if (typeof uri === "string") { + params = { + uri: uri + }; + } + } else { + params = _extends_1({}, options, { + uri: uri + }); + } + + params.callback = callback; + return params; + } + + function createXHR(uri, options, callback) { + options = initParams(uri, options, callback); + return _createXHR(options); + } + + function _createXHR(options) { + if (typeof options.callback === "undefined") { + throw new Error("callback argument missing"); + } + + var called = false; + + var callback = function cbOnce(err, response, body) { + if (!called) { + called = true; + options.callback(err, response, body); + } + }; + + function readystatechange() { + if (xhr.readyState === 4) { + setTimeout(loadFunc, 0); + } + } + + function getBody() { + // Chrome with requestType=blob throws errors arround when even testing access to responseText + var body = undefined; + + if (xhr.response) { + body = xhr.response; + } else { + body = xhr.responseText || getXml(xhr); + } + + if (isJson) { + try { + body = JSON.parse(body); + } catch (e) {} + } + + return body; + } + + function errorFunc(evt) { + clearTimeout(timeoutTimer); + + if (!(evt instanceof Error)) { + evt = new Error("" + (evt || "Unknown XMLHttpRequest Error")); + } + + evt.statusCode = 0; + return callback(evt, failureResponse); + } // will load the data & process the response in a special response object + + + function loadFunc() { + if (aborted) return; + var status; + clearTimeout(timeoutTimer); + + if (options.useXDR && xhr.status === undefined) { + //IE8 CORS GET successful response doesn't have a status field, but body is fine + status = 200; + } else { + status = xhr.status === 1223 ? 204 : xhr.status; + } + + var response = failureResponse; + var err = null; + + if (status !== 0) { + response = { + body: getBody(), + statusCode: status, + method: method, + headers: {}, + url: uri, + rawRequest: xhr + }; + + if (xhr.getAllResponseHeaders) { + //remember xhr can in fact be XDR for CORS in IE + response.headers = parseHeaders(xhr.getAllResponseHeaders()); + } + } else { + err = new Error("Internal XMLHttpRequest Error"); + } + + return callback(err, response, response.body); + } + + var xhr = options.xhr || null; + + if (!xhr) { + if (options.cors || options.useXDR) { + xhr = new createXHR.XDomainRequest(); + } else { + xhr = new createXHR.XMLHttpRequest(); + } + } + + var key; + var aborted; + var uri = xhr.url = options.uri || options.url; + var method = xhr.method = options.method || "GET"; + var body = options.body || options.data; + var headers = xhr.headers = options.headers || {}; + var sync = !!options.sync; + var isJson = false; + var timeoutTimer; + var failureResponse = { + body: undefined, + headers: {}, + statusCode: 0, + method: method, + url: uri, + rawRequest: xhr + }; + + if ("json" in options && options.json !== false) { + isJson = true; + headers["accept"] || headers["Accept"] || (headers["Accept"] = "application/json"); //Don't override existing accept header declared by user + + if (method !== "GET" && method !== "HEAD") { + headers["content-type"] || headers["Content-Type"] || (headers["Content-Type"] = "application/json"); //Don't override existing accept header declared by user + + body = JSON.stringify(options.json === true ? body : options.json); + } + } + + xhr.onreadystatechange = readystatechange; + xhr.onload = loadFunc; + xhr.onerror = errorFunc; // IE9 must have onprogress be set to a unique function. + + xhr.onprogress = function () {// IE must die + }; + + xhr.onabort = function () { + aborted = true; + }; + + xhr.ontimeout = errorFunc; + xhr.open(method, uri, !sync, options.username, options.password); //has to be after open + + if (!sync) { + xhr.withCredentials = !!options.withCredentials; + } // Cannot set timeout with sync request + // not setting timeout on the xhr object, because of old webkits etc. not handling that correctly + // both npm's request and jquery 1.x use this kind of timeout, so this is being consistent + + + if (!sync && options.timeout > 0) { + timeoutTimer = setTimeout(function () { + if (aborted) return; + aborted = true; //IE9 may still call readystatechange + + xhr.abort("timeout"); + var e = new Error("XMLHttpRequest timeout"); + e.code = "ETIMEDOUT"; + errorFunc(e); + }, options.timeout); + } + + if (xhr.setRequestHeader) { + for (key in headers) { + if (headers.hasOwnProperty(key)) { + xhr.setRequestHeader(key, headers[key]); + } + } + } else if (options.headers && !isEmpty(options.headers)) { + throw new Error("Headers cannot be set on an XDomainRequest object"); + } + + if ("responseType" in options) { + xhr.responseType = options.responseType; + } + + if ("beforeSend" in options && typeof options.beforeSend === "function") { + options.beforeSend(xhr); + } // Microsoft Edge browser sends "undefined" when send is called with undefined value. + // XMLHttpRequest spec says to pass null as body to indicate no body + // See https://github.com/naugtur/xhr/issues/100. + + + xhr.send(body || null); + return xhr; + } + + function getXml(xhr) { + // xhr.responseXML will throw Exception "InvalidStateError" or "DOMException" + // See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseXML. + try { + if (xhr.responseType === "document") { + return xhr.responseXML; + } + + var firefoxBugTakenEffect = xhr.responseXML && xhr.responseXML.documentElement.nodeName === "parsererror"; + + if (xhr.responseType === "" && !firefoxBugTakenEffect) { + return xhr.responseXML; + } + } catch (e) {} + + return null; + } + + function noop() {} + xhr["default"] = default_1; + + /** + * Takes a webvtt file contents and parses it into cues + * + * @param {string} srcContent + * webVTT file contents + * + * @param {TextTrack} track + * TextTrack to add cues to. Cues come from the srcContent. + * + * @private + */ + + var parseCues = function parseCues(srcContent, track) { + var parser = new window$1.WebVTT.Parser(window$1, window$1.vttjs, window$1.WebVTT.StringDecoder()); + var errors = []; + + parser.oncue = function (cue) { + track.addCue(cue); + }; + + parser.onparsingerror = function (error) { + errors.push(error); + }; + + parser.onflush = function () { + track.trigger({ + type: 'loadeddata', + target: track + }); + }; + + parser.parse(srcContent); + + if (errors.length > 0) { + if (window$1.console && window$1.console.groupCollapsed) { + window$1.console.groupCollapsed("Text Track parsing errors for " + track.src); + } + + errors.forEach(function (error) { + return log.error(error); + }); + + if (window$1.console && window$1.console.groupEnd) { + window$1.console.groupEnd(); + } + } + + parser.flush(); + }; + /** + * Load a `TextTrack` from a specified url. + * + * @param {string} src + * Url to load track from. + * + * @param {TextTrack} track + * Track to add cues to. Comes from the content at the end of `url`. + * + * @private + */ + + + var loadTrack = function loadTrack(src, track) { + var opts = { + uri: src + }; + var crossOrigin = isCrossOrigin(src); + + if (crossOrigin) { + opts.cors = crossOrigin; + } + + xhr(opts, bind(this, function (err, response, responseBody) { + if (err) { + return log.error(err, response); + } + + track.loaded_ = true; // Make sure that vttjs has loaded, otherwise, wait till it finished loading + // NOTE: this is only used for the alt/video.novtt.js build + + if (typeof window$1.WebVTT !== 'function') { + if (track.tech_) { + // to prevent use before define eslint error, we define loadHandler + // as a let here + track.tech_.any(['vttjsloaded', 'vttjserror'], function (event) { + if (event.type === 'vttjserror') { + log.error("vttjs failed to load, stopping trying to process " + track.src); + return; + } + + return parseCues(responseBody, track); + }); + } + } else { + parseCues(responseBody, track); + } + })); + }; + /** + * A representation of a single `TextTrack`. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#texttrack} + * @extends Track + */ + + + var TextTrack = + /*#__PURE__*/ + function (_Track) { + inheritsLoose(TextTrack, _Track); + + /** + * Create an instance of this class. + * + * @param {Object} options={} + * Object of option names and values + * + * @param {Tech} options.tech + * A reference to the tech that owns this TextTrack. + * + * @param {TextTrack~Kind} [options.kind='subtitles'] + * A valid text track kind. + * + * @param {TextTrack~Mode} [options.mode='disabled'] + * A valid text track mode. + * + * @param {string} [options.id='vjs_track_' + Guid.newGUID()] + * A unique id for this TextTrack. + * + * @param {string} [options.label=''] + * The menu label for this track. + * + * @param {string} [options.language=''] + * A valid two character language code. + * + * @param {string} [options.srclang=''] + * A valid two character language code. An alternative, but deprioritized + * version of `options.language` + * + * @param {string} [options.src] + * A url to TextTrack cues. + * + * @param {boolean} [options.default] + * If this track should default to on or off. + */ + function TextTrack(options) { + var _this; + + if (options === void 0) { + options = {}; + } + + if (!options.tech) { + throw new Error('A tech was not provided.'); + } + + var settings = mergeOptions(options, { + kind: TextTrackKind[options.kind] || 'subtitles', + language: options.language || options.srclang || '' + }); + var mode = TextTrackMode[settings.mode] || 'disabled'; + var default_ = settings["default"]; + + if (settings.kind === 'metadata' || settings.kind === 'chapters') { + mode = 'hidden'; + } + + _this = _Track.call(this, settings) || this; + _this.tech_ = settings.tech; + _this.cues_ = []; + _this.activeCues_ = []; + _this.preload_ = _this.tech_.preloadTextTracks !== false; + var cues = new TextTrackCueList(_this.cues_); + var activeCues = new TextTrackCueList(_this.activeCues_); + var changed = false; + var timeupdateHandler = bind(assertThisInitialized(_this), function () { + // Accessing this.activeCues for the side-effects of updating itself + // due to its nature as a getter function. Do not remove or cues will + // stop updating! + // Use the setter to prevent deletion from uglify (pure_getters rule) + this.activeCues = this.activeCues; + + if (changed) { + this.trigger('cuechange'); + changed = false; + } + }); + + if (mode !== 'disabled') { + _this.tech_.ready(function () { + _this.tech_.on('timeupdate', timeupdateHandler); + }, true); + } + + Object.defineProperties(assertThisInitialized(_this), { + /** + * @memberof TextTrack + * @member {boolean} default + * If this track was set to be on or off by default. Cannot be changed after + * creation. + * @instance + * + * @readonly + */ + "default": { + get: function get() { + return default_; + }, + set: function set() {} + }, + + /** + * @memberof TextTrack + * @member {string} mode + * Set the mode of this TextTrack to a valid {@link TextTrack~Mode}. Will + * not be set if setting to an invalid mode. + * @instance + * + * @fires TextTrack#modechange + */ + mode: { + get: function get() { + return mode; + }, + set: function set(newMode) { + var _this2 = this; + + if (!TextTrackMode[newMode]) { + return; + } + + mode = newMode; + + if (!this.preload_ && mode !== 'disabled' && this.cues.length === 0) { + // On-demand load. + loadTrack(this.src, this); + } + + if (mode !== 'disabled') { + this.tech_.ready(function () { + _this2.tech_.on('timeupdate', timeupdateHandler); + }, true); + } else { + this.tech_.off('timeupdate', timeupdateHandler); + } + /** + * An event that fires when mode changes on this track. This allows + * the TextTrackList that holds this track to act accordingly. + * + * > Note: This is not part of the spec! + * + * @event TextTrack#modechange + * @type {EventTarget~Event} + */ + + + this.trigger('modechange'); + } + }, + + /** + * @memberof TextTrack + * @member {TextTrackCueList} cues + * The text track cue list for this TextTrack. + * @instance + */ + cues: { + get: function get() { + if (!this.loaded_) { + return null; + } + + return cues; + }, + set: function set() {} + }, + + /** + * @memberof TextTrack + * @member {TextTrackCueList} activeCues + * The list text track cues that are currently active for this TextTrack. + * @instance + */ + activeCues: { + get: function get() { + if (!this.loaded_) { + return null; + } // nothing to do + + + if (this.cues.length === 0) { + return activeCues; + } + + var ct = this.tech_.currentTime(); + var active = []; + + for (var i = 0, l = this.cues.length; i < l; i++) { + var cue = this.cues[i]; + + if (cue.startTime <= ct && cue.endTime >= ct) { + active.push(cue); + } else if (cue.startTime === cue.endTime && cue.startTime <= ct && cue.startTime + 0.5 >= ct) { + active.push(cue); + } + } + + changed = false; + + if (active.length !== this.activeCues_.length) { + changed = true; + } else { + for (var _i = 0; _i < active.length; _i++) { + if (this.activeCues_.indexOf(active[_i]) === -1) { + changed = true; + } + } + } + + this.activeCues_ = active; + activeCues.setCues_(this.activeCues_); + return activeCues; + }, + // /!\ Keep this setter empty (see the timeupdate handler above) + set: function set() {} + } + }); + + if (settings.src) { + _this.src = settings.src; + + if (!_this.preload_) { + // Tracks will load on-demand. + // Act like we're loaded for other purposes. + _this.loaded_ = true; + } + + if (_this.preload_ || default_ || settings.kind !== 'subtitles' && settings.kind !== 'captions') { + loadTrack(_this.src, assertThisInitialized(_this)); + } + } else { + _this.loaded_ = true; + } + + return _this; + } + /** + * Add a cue to the internal list of cues. + * + * @param {TextTrack~Cue} cue + * The cue to add to our internal list + */ + + + var _proto = TextTrack.prototype; + + _proto.addCue = function addCue(originalCue) { + var cue = originalCue; + + if (window$1.vttjs && !(originalCue instanceof window$1.vttjs.VTTCue)) { + cue = new window$1.vttjs.VTTCue(originalCue.startTime, originalCue.endTime, originalCue.text); + + for (var prop in originalCue) { + if (!(prop in cue)) { + cue[prop] = originalCue[prop]; + } + } // make sure that `id` is copied over + + + cue.id = originalCue.id; + cue.originalCue_ = originalCue; + } + + var tracks = this.tech_.textTracks(); + + for (var i = 0; i < tracks.length; i++) { + if (tracks[i] !== this) { + tracks[i].removeCue(cue); + } + } + + this.cues_.push(cue); + this.cues.setCues_(this.cues_); + } + /** + * Remove a cue from our internal list + * + * @param {TextTrack~Cue} removeCue + * The cue to remove from our internal list + */ + ; + + _proto.removeCue = function removeCue(_removeCue) { + var i = this.cues_.length; + + while (i--) { + var cue = this.cues_[i]; + + if (cue === _removeCue || cue.originalCue_ && cue.originalCue_ === _removeCue) { + this.cues_.splice(i, 1); + this.cues.setCues_(this.cues_); + break; + } + } + }; + + return TextTrack; + }(Track); + /** + * cuechange - One or more cues in the track have become active or stopped being active. + */ + + + TextTrack.prototype.allowedEvents_ = { + cuechange: 'cuechange' + }; + + /** + * A representation of a single `AudioTrack`. If it is part of an {@link AudioTrackList} + * only one `AudioTrack` in the list will be enabled at a time. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#audiotrack} + * @extends Track + */ + + var AudioTrack = + /*#__PURE__*/ + function (_Track) { + inheritsLoose(AudioTrack, _Track); + + /** + * Create an instance of this class. + * + * @param {Object} [options={}] + * Object of option names and values + * + * @param {AudioTrack~Kind} [options.kind=''] + * A valid audio track kind + * + * @param {string} [options.id='vjs_track_' + Guid.newGUID()] + * A unique id for this AudioTrack. + * + * @param {string} [options.label=''] + * The menu label for this track. + * + * @param {string} [options.language=''] + * A valid two character language code. + * + * @param {boolean} [options.enabled] + * If this track is the one that is currently playing. If this track is part of + * an {@link AudioTrackList}, only one {@link AudioTrack} will be enabled. + */ + function AudioTrack(options) { + var _this; + + if (options === void 0) { + options = {}; + } + + var settings = mergeOptions(options, { + kind: AudioTrackKind[options.kind] || '' + }); + _this = _Track.call(this, settings) || this; + var enabled = false; + /** + * @memberof AudioTrack + * @member {boolean} enabled + * If this `AudioTrack` is enabled or not. When setting this will + * fire {@link AudioTrack#enabledchange} if the state of enabled is changed. + * @instance + * + * @fires VideoTrack#selectedchange + */ + + Object.defineProperty(assertThisInitialized(_this), 'enabled', { + get: function get() { + return enabled; + }, + set: function set(newEnabled) { + // an invalid or unchanged value + if (typeof newEnabled !== 'boolean' || newEnabled === enabled) { + return; + } + + enabled = newEnabled; + /** + * An event that fires when enabled changes on this track. This allows + * the AudioTrackList that holds this track to act accordingly. + * + * > Note: This is not part of the spec! Native tracks will do + * this internally without an event. + * + * @event AudioTrack#enabledchange + * @type {EventTarget~Event} + */ + + this.trigger('enabledchange'); + } + }); // if the user sets this track to selected then + // set selected to that true value otherwise + // we keep it false + + if (settings.enabled) { + _this.enabled = settings.enabled; + } + + _this.loaded_ = true; + return _this; + } + + return AudioTrack; + }(Track); + + /** + * A representation of a single `VideoTrack`. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#videotrack} + * @extends Track + */ + + var VideoTrack = + /*#__PURE__*/ + function (_Track) { + inheritsLoose(VideoTrack, _Track); + + /** + * Create an instance of this class. + * + * @param {Object} [options={}] + * Object of option names and values + * + * @param {string} [options.kind=''] + * A valid {@link VideoTrack~Kind} + * + * @param {string} [options.id='vjs_track_' + Guid.newGUID()] + * A unique id for this AudioTrack. + * + * @param {string} [options.label=''] + * The menu label for this track. + * + * @param {string} [options.language=''] + * A valid two character language code. + * + * @param {boolean} [options.selected] + * If this track is the one that is currently playing. + */ + function VideoTrack(options) { + var _this; + + if (options === void 0) { + options = {}; + } + + var settings = mergeOptions(options, { + kind: VideoTrackKind[options.kind] || '' + }); + _this = _Track.call(this, settings) || this; + var selected = false; + /** + * @memberof VideoTrack + * @member {boolean} selected + * If this `VideoTrack` is selected or not. When setting this will + * fire {@link VideoTrack#selectedchange} if the state of selected changed. + * @instance + * + * @fires VideoTrack#selectedchange + */ + + Object.defineProperty(assertThisInitialized(_this), 'selected', { + get: function get() { + return selected; + }, + set: function set(newSelected) { + // an invalid or unchanged value + if (typeof newSelected !== 'boolean' || newSelected === selected) { + return; + } + + selected = newSelected; + /** + * An event that fires when selected changes on this track. This allows + * the VideoTrackList that holds this track to act accordingly. + * + * > Note: This is not part of the spec! Native tracks will do + * this internally without an event. + * + * @event VideoTrack#selectedchange + * @type {EventTarget~Event} + */ + + this.trigger('selectedchange'); + } + }); // if the user sets this track to selected then + // set selected to that true value otherwise + // we keep it false + + if (settings.selected) { + _this.selected = settings.selected; + } + + return _this; + } + + return VideoTrack; + }(Track); + + /** + * @memberof HTMLTrackElement + * @typedef {HTMLTrackElement~ReadyState} + * @enum {number} + */ + + var NONE = 0; + var LOADING = 1; + var LOADED = 2; + var ERROR = 3; + /** + * A single track represented in the DOM. + * + * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#htmltrackelement} + * @extends EventTarget + */ + + var HTMLTrackElement = + /*#__PURE__*/ + function (_EventTarget) { + inheritsLoose(HTMLTrackElement, _EventTarget); + + /** + * Create an instance of this class. + * + * @param {Object} options={} + * Object of option names and values + * + * @param {Tech} options.tech + * A reference to the tech that owns this HTMLTrackElement. + * + * @param {TextTrack~Kind} [options.kind='subtitles'] + * A valid text track kind. + * + * @param {TextTrack~Mode} [options.mode='disabled'] + * A valid text track mode. + * + * @param {string} [options.id='vjs_track_' + Guid.newGUID()] + * A unique id for this TextTrack. + * + * @param {string} [options.label=''] + * The menu label for this track. + * + * @param {string} [options.language=''] + * A valid two character language code. + * + * @param {string} [options.srclang=''] + * A valid two character language code. An alternative, but deprioritized + * vesion of `options.language` + * + * @param {string} [options.src] + * A url to TextTrack cues. + * + * @param {boolean} [options.default] + * If this track should default to on or off. + */ + function HTMLTrackElement(options) { + var _this; + + if (options === void 0) { + options = {}; + } + + _this = _EventTarget.call(this) || this; + var readyState; + var track = new TextTrack(options); + _this.kind = track.kind; + _this.src = track.src; + _this.srclang = track.language; + _this.label = track.label; + _this["default"] = track["default"]; + Object.defineProperties(assertThisInitialized(_this), { + /** + * @memberof HTMLTrackElement + * @member {HTMLTrackElement~ReadyState} readyState + * The current ready state of the track element. + * @instance + */ + readyState: { + get: function get() { + return readyState; + } + }, + + /** + * @memberof HTMLTrackElement + * @member {TextTrack} track + * The underlying TextTrack object. + * @instance + * + */ + track: { + get: function get() { + return track; + } + } + }); + readyState = NONE; + /** + * @listens TextTrack#loadeddata + * @fires HTMLTrackElement#load + */ + + track.addEventListener('loadeddata', function () { + readyState = LOADED; + + _this.trigger({ + type: 'load', + target: assertThisInitialized(_this) + }); + }); + return _this; + } + + return HTMLTrackElement; + }(EventTarget); + + HTMLTrackElement.prototype.allowedEvents_ = { + load: 'load' + }; + HTMLTrackElement.NONE = NONE; + HTMLTrackElement.LOADING = LOADING; + HTMLTrackElement.LOADED = LOADED; + HTMLTrackElement.ERROR = ERROR; + + /* + * This file contains all track properties that are used in + * player.js, tech.js, html5.js and possibly other techs in the future. + */ + + var NORMAL = { + audio: { + ListClass: AudioTrackList, + TrackClass: AudioTrack, + capitalName: 'Audio' + }, + video: { + ListClass: VideoTrackList, + TrackClass: VideoTrack, + capitalName: 'Video' + }, + text: { + ListClass: TextTrackList, + TrackClass: TextTrack, + capitalName: 'Text' + } + }; + Object.keys(NORMAL).forEach(function (type) { + NORMAL[type].getterName = type + "Tracks"; + NORMAL[type].privateName = type + "Tracks_"; + }); + var REMOTE = { + remoteText: { + ListClass: TextTrackList, + TrackClass: TextTrack, + capitalName: 'RemoteText', + getterName: 'remoteTextTracks', + privateName: 'remoteTextTracks_' + }, + remoteTextEl: { + ListClass: HtmlTrackElementList, + TrackClass: HTMLTrackElement, + capitalName: 'RemoteTextTrackEls', + getterName: 'remoteTextTrackEls', + privateName: 'remoteTextTrackEls_' + } + }; + + var ALL = _extends_1({}, NORMAL, REMOTE); + + REMOTE.names = Object.keys(REMOTE); + NORMAL.names = Object.keys(NORMAL); + ALL.names = [].concat(REMOTE.names).concat(NORMAL.names); + + /** + * Copyright 2013 vtt.js Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ + + /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ + + var _objCreate = Object.create || function () { + function F() {} + + return function (o) { + if (arguments.length !== 1) { + throw new Error('Object.create shim only accepts one parameter.'); + } + + F.prototype = o; + return new F(); + }; + }(); // Creates a new ParserError object from an errorData object. The errorData + // object should have default code and message properties. The default message + // property can be overriden by passing in a message parameter. + // See ParsingError.Errors below for acceptable errors. + + + function ParsingError(errorData, message) { + this.name = "ParsingError"; + this.code = errorData.code; + this.message = message || errorData.message; + } + + ParsingError.prototype = _objCreate(Error.prototype); + ParsingError.prototype.constructor = ParsingError; // ParsingError metadata for acceptable ParsingErrors. + + ParsingError.Errors = { + BadSignature: { + code: 0, + message: "Malformed WebVTT signature." + }, + BadTimeStamp: { + code: 1, + message: "Malformed time stamp." + } + }; // Try to parse input as a time stamp. + + function parseTimeStamp(input) { + function computeSeconds(h, m, s, f) { + return (h | 0) * 3600 + (m | 0) * 60 + (s | 0) + (f | 0) / 1000; + } + + var m = input.match(/^(\d+):(\d{1,2})(:\d{1,2})?\.(\d{3})/); + + if (!m) { + return null; + } + + if (m[3]) { + // Timestamp takes the form of [hours]:[minutes]:[seconds].[milliseconds] + return computeSeconds(m[1], m[2], m[3].replace(":", ""), m[4]); + } else if (m[1] > 59) { + // Timestamp takes the form of [hours]:[minutes].[milliseconds] + // First position is hours as it's over 59. + return computeSeconds(m[1], m[2], 0, m[4]); + } else { + // Timestamp takes the form of [minutes]:[seconds].[milliseconds] + return computeSeconds(0, m[1], m[2], m[4]); + } + } // A settings object holds key/value pairs and will ignore anything but the first + // assignment to a specific key. + + + function Settings() { + this.values = _objCreate(null); + } + + Settings.prototype = { + // Only accept the first assignment to any key. + set: function set(k, v) { + if (!this.get(k) && v !== "") { + this.values[k] = v; + } + }, + // Return the value for a key, or a default value. + // If 'defaultKey' is passed then 'dflt' is assumed to be an object with + // a number of possible default values as properties where 'defaultKey' is + // the key of the property that will be chosen; otherwise it's assumed to be + // a single value. + get: function get(k, dflt, defaultKey) { + if (defaultKey) { + return this.has(k) ? this.values[k] : dflt[defaultKey]; + } + + return this.has(k) ? this.values[k] : dflt; + }, + // Check whether we have a value for a key. + has: function has(k) { + return k in this.values; + }, + // Accept a setting if its one of the given alternatives. + alt: function alt(k, v, a) { + for (var n = 0; n < a.length; ++n) { + if (v === a[n]) { + this.set(k, v); + break; + } + } + }, + // Accept a setting if its a valid (signed) integer. + integer: function integer(k, v) { + if (/^-?\d+$/.test(v)) { + // integer + this.set(k, parseInt(v, 10)); + } + }, + // Accept a setting if its a valid percentage. + percent: function percent(k, v) { + var m; + + if (m = v.match(/^([\d]{1,3})(\.[\d]*)?%$/)) { + v = parseFloat(v); + + if (v >= 0 && v <= 100) { + this.set(k, v); + return true; + } + } + + return false; + } + }; // Helper function to parse input into groups separated by 'groupDelim', and + // interprete each group as a key/value pair separated by 'keyValueDelim'. + + function parseOptions(input, callback, keyValueDelim, groupDelim) { + var groups = groupDelim ? input.split(groupDelim) : [input]; + + for (var i in groups) { + if (typeof groups[i] !== "string") { + continue; + } + + var kv = groups[i].split(keyValueDelim); + + if (kv.length !== 2) { + continue; + } + + var k = kv[0]; + var v = kv[1]; + callback(k, v); + } + } + + function parseCue(input, cue, regionList) { + // Remember the original input if we need to throw an error. + var oInput = input; // 4.1 WebVTT timestamp + + function consumeTimeStamp() { + var ts = parseTimeStamp(input); + + if (ts === null) { + throw new ParsingError(ParsingError.Errors.BadTimeStamp, "Malformed timestamp: " + oInput); + } // Remove time stamp from input. + + + input = input.replace(/^[^\sa-zA-Z-]+/, ""); + return ts; + } // 4.4.2 WebVTT cue settings + + + function consumeCueSettings(input, cue) { + var settings = new Settings(); + parseOptions(input, function (k, v) { + switch (k) { + case "region": + // Find the last region we parsed with the same region id. + for (var i = regionList.length - 1; i >= 0; i--) { + if (regionList[i].id === v) { + settings.set(k, regionList[i].region); + break; + } + } + + break; + + case "vertical": + settings.alt(k, v, ["rl", "lr"]); + break; + + case "line": + var vals = v.split(","), + vals0 = vals[0]; + settings.integer(k, vals0); + settings.percent(k, vals0) ? settings.set("snapToLines", false) : null; + settings.alt(k, vals0, ["auto"]); + + if (vals.length === 2) { + settings.alt("lineAlign", vals[1], ["start", "center", "end"]); + } + + break; + + case "position": + vals = v.split(","); + settings.percent(k, vals[0]); + + if (vals.length === 2) { + settings.alt("positionAlign", vals[1], ["start", "center", "end"]); + } + + break; + + case "size": + settings.percent(k, v); + break; + + case "align": + settings.alt(k, v, ["start", "center", "end", "left", "right"]); + break; + } + }, /:/, /\s/); // Apply default values for any missing fields. + + cue.region = settings.get("region", null); + cue.vertical = settings.get("vertical", ""); + + try { + cue.line = settings.get("line", "auto"); + } catch (e) {} + + cue.lineAlign = settings.get("lineAlign", "start"); + cue.snapToLines = settings.get("snapToLines", true); + cue.size = settings.get("size", 100); // Safari still uses the old middle value and won't accept center + + try { + cue.align = settings.get("align", "center"); + } catch (e) { + cue.align = settings.get("align", "middle"); + } + + try { + cue.position = settings.get("position", "auto"); + } catch (e) { + cue.position = settings.get("position", { + start: 0, + left: 0, + center: 50, + middle: 50, + end: 100, + right: 100 + }, cue.align); + } + + cue.positionAlign = settings.get("positionAlign", { + start: "start", + left: "start", + center: "center", + middle: "center", + end: "end", + right: "end" + }, cue.align); + } + + function skipWhitespace() { + input = input.replace(/^\s+/, ""); + } // 4.1 WebVTT cue timings. + + + skipWhitespace(); + cue.startTime = consumeTimeStamp(); // (1) collect cue start time + + skipWhitespace(); + + if (input.substr(0, 3) !== "-->") { + // (3) next characters must match "-->" + throw new ParsingError(ParsingError.Errors.BadTimeStamp, "Malformed time stamp (time stamps must be separated by '-->'): " + oInput); + } + + input = input.substr(3); + skipWhitespace(); + cue.endTime = consumeTimeStamp(); // (5) collect cue end time + // 4.1 WebVTT cue settings list. + + skipWhitespace(); + consumeCueSettings(input, cue); + } + + var TEXTAREA_ELEMENT = document.createElement("textarea"); + var TAG_NAME = { + c: "span", + i: "i", + b: "b", + u: "u", + ruby: "ruby", + rt: "rt", + v: "span", + lang: "span" + }; // 5.1 default text color + // 5.2 default text background color is equivalent to text color with bg_ prefix + + var DEFAULT_COLOR_CLASS = { + white: 'rgba(255,255,255,1)', + lime: 'rgba(0,255,0,1)', + cyan: 'rgba(0,255,255,1)', + red: 'rgba(255,0,0,1)', + yellow: 'rgba(255,255,0,1)', + magenta: 'rgba(255,0,255,1)', + blue: 'rgba(0,0,255,1)', + black: 'rgba(0,0,0,1)' + }; + var TAG_ANNOTATION = { + v: "title", + lang: "lang" + }; + var NEEDS_PARENT = { + rt: "ruby" + }; // Parse content into a document fragment. + + function parseContent(window, input) { + function nextToken() { + // Check for end-of-string. + if (!input) { + return null; + } // Consume 'n' characters from the input. + + + function consume(result) { + input = input.substr(result.length); + return result; + } + + var m = input.match(/^([^<]*)(<[^>]*>?)?/); // If there is some text before the next tag, return it, otherwise return + // the tag. + + return consume(m[1] ? m[1] : m[2]); + } + + function unescape(s) { + TEXTAREA_ELEMENT.innerHTML = s; + s = TEXTAREA_ELEMENT.textContent; + TEXTAREA_ELEMENT.textContent = ""; + return s; + } + + function shouldAdd(current, element) { + return !NEEDS_PARENT[element.localName] || NEEDS_PARENT[element.localName] === current.localName; + } // Create an element for this tag. + + + function createElement(type, annotation) { + var tagName = TAG_NAME[type]; + + if (!tagName) { + return null; + } + + var element = window.document.createElement(tagName); + var name = TAG_ANNOTATION[type]; + + if (name && annotation) { + element[name] = annotation.trim(); + } + + return element; + } + + var rootDiv = window.document.createElement("div"), + current = rootDiv, + t, + tagStack = []; + + while ((t = nextToken()) !== null) { + if (t[0] === '<') { + if (t[1] === "/") { + // If the closing tag matches, move back up to the parent node. + if (tagStack.length && tagStack[tagStack.length - 1] === t.substr(2).replace(">", "")) { + tagStack.pop(); + current = current.parentNode; + } // Otherwise just ignore the end tag. + + + continue; + } + + var ts = parseTimeStamp(t.substr(1, t.length - 2)); + var node; + + if (ts) { + // Timestamps are lead nodes as well. + node = window.document.createProcessingInstruction("timestamp", ts); + current.appendChild(node); + continue; + } + + var m = t.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/); // If we can't parse the tag, skip to the next tag. + + if (!m) { + continue; + } // Try to construct an element, and ignore the tag if we couldn't. + + + node = createElement(m[1], m[3]); + + if (!node) { + continue; + } // Determine if the tag should be added based on the context of where it + // is placed in the cuetext. + + + if (!shouldAdd(current, node)) { + continue; + } // Set the class list (as a list of classes, separated by space). + + + if (m[2]) { + var classes = m[2].split('.'); + classes.forEach(function (cl) { + var bgColor = /^bg_/.test(cl); // slice out `bg_` if it's a background color + + var colorName = bgColor ? cl.slice(3) : cl; + + if (DEFAULT_COLOR_CLASS.hasOwnProperty(colorName)) { + var propName = bgColor ? 'background-color' : 'color'; + var propValue = DEFAULT_COLOR_CLASS[colorName]; + node.style[propName] = propValue; + } + }); + node.className = classes.join(' '); + } // Append the node to the current node, and enter the scope of the new + // node. + + + tagStack.push(m[1]); + current.appendChild(node); + current = node; + continue; + } // Text nodes are leaf nodes. + + + current.appendChild(window.document.createTextNode(unescape(t))); + } + + return rootDiv; + } // This is a list of all the Unicode characters that have a strong + // right-to-left category. What this means is that these characters are + // written right-to-left for sure. It was generated by pulling all the strong + // right-to-left characters out of the Unicode data table. That table can + // found at: http://www.unicode.org/Public/UNIDATA/UnicodeData.txt + + + var strongRTLRanges = [[0x5be, 0x5be], [0x5c0, 0x5c0], [0x5c3, 0x5c3], [0x5c6, 0x5c6], [0x5d0, 0x5ea], [0x5f0, 0x5f4], [0x608, 0x608], [0x60b, 0x60b], [0x60d, 0x60d], [0x61b, 0x61b], [0x61e, 0x64a], [0x66d, 0x66f], [0x671, 0x6d5], [0x6e5, 0x6e6], [0x6ee, 0x6ef], [0x6fa, 0x70d], [0x70f, 0x710], [0x712, 0x72f], [0x74d, 0x7a5], [0x7b1, 0x7b1], [0x7c0, 0x7ea], [0x7f4, 0x7f5], [0x7fa, 0x7fa], [0x800, 0x815], [0x81a, 0x81a], [0x824, 0x824], [0x828, 0x828], [0x830, 0x83e], [0x840, 0x858], [0x85e, 0x85e], [0x8a0, 0x8a0], [0x8a2, 0x8ac], [0x200f, 0x200f], [0xfb1d, 0xfb1d], [0xfb1f, 0xfb28], [0xfb2a, 0xfb36], [0xfb38, 0xfb3c], [0xfb3e, 0xfb3e], [0xfb40, 0xfb41], [0xfb43, 0xfb44], [0xfb46, 0xfbc1], [0xfbd3, 0xfd3d], [0xfd50, 0xfd8f], [0xfd92, 0xfdc7], [0xfdf0, 0xfdfc], [0xfe70, 0xfe74], [0xfe76, 0xfefc], [0x10800, 0x10805], [0x10808, 0x10808], [0x1080a, 0x10835], [0x10837, 0x10838], [0x1083c, 0x1083c], [0x1083f, 0x10855], [0x10857, 0x1085f], [0x10900, 0x1091b], [0x10920, 0x10939], [0x1093f, 0x1093f], [0x10980, 0x109b7], [0x109be, 0x109bf], [0x10a00, 0x10a00], [0x10a10, 0x10a13], [0x10a15, 0x10a17], [0x10a19, 0x10a33], [0x10a40, 0x10a47], [0x10a50, 0x10a58], [0x10a60, 0x10a7f], [0x10b00, 0x10b35], [0x10b40, 0x10b55], [0x10b58, 0x10b72], [0x10b78, 0x10b7f], [0x10c00, 0x10c48], [0x1ee00, 0x1ee03], [0x1ee05, 0x1ee1f], [0x1ee21, 0x1ee22], [0x1ee24, 0x1ee24], [0x1ee27, 0x1ee27], [0x1ee29, 0x1ee32], [0x1ee34, 0x1ee37], [0x1ee39, 0x1ee39], [0x1ee3b, 0x1ee3b], [0x1ee42, 0x1ee42], [0x1ee47, 0x1ee47], [0x1ee49, 0x1ee49], [0x1ee4b, 0x1ee4b], [0x1ee4d, 0x1ee4f], [0x1ee51, 0x1ee52], [0x1ee54, 0x1ee54], [0x1ee57, 0x1ee57], [0x1ee59, 0x1ee59], [0x1ee5b, 0x1ee5b], [0x1ee5d, 0x1ee5d], [0x1ee5f, 0x1ee5f], [0x1ee61, 0x1ee62], [0x1ee64, 0x1ee64], [0x1ee67, 0x1ee6a], [0x1ee6c, 0x1ee72], [0x1ee74, 0x1ee77], [0x1ee79, 0x1ee7c], [0x1ee7e, 0x1ee7e], [0x1ee80, 0x1ee89], [0x1ee8b, 0x1ee9b], [0x1eea1, 0x1eea3], [0x1eea5, 0x1eea9], [0x1eeab, 0x1eebb], [0x10fffd, 0x10fffd]]; + + function isStrongRTLChar(charCode) { + for (var i = 0; i < strongRTLRanges.length; i++) { + var currentRange = strongRTLRanges[i]; + + if (charCode >= currentRange[0] && charCode <= currentRange[1]) { + return true; + } + } + + return false; + } + + function determineBidi(cueDiv) { + var nodeStack = [], + text = "", + charCode; + + if (!cueDiv || !cueDiv.childNodes) { + return "ltr"; + } + + function pushNodes(nodeStack, node) { + for (var i = node.childNodes.length - 1; i >= 0; i--) { + nodeStack.push(node.childNodes[i]); + } + } + + function nextTextNode(nodeStack) { + if (!nodeStack || !nodeStack.length) { + return null; + } + + var node = nodeStack.pop(), + text = node.textContent || node.innerText; + + if (text) { + // TODO: This should match all unicode type B characters (paragraph + // separator characters). See issue #115. + var m = text.match(/^.*(\n|\r)/); + + if (m) { + nodeStack.length = 0; + return m[0]; + } + + return text; + } + + if (node.tagName === "ruby") { + return nextTextNode(nodeStack); + } + + if (node.childNodes) { + pushNodes(nodeStack, node); + return nextTextNode(nodeStack); + } + } + + pushNodes(nodeStack, cueDiv); + + while (text = nextTextNode(nodeStack)) { + for (var i = 0; i < text.length; i++) { + charCode = text.charCodeAt(i); + + if (isStrongRTLChar(charCode)) { + return "rtl"; + } + } + } + + return "ltr"; + } + + function computeLinePos(cue) { + if (typeof cue.line === "number" && (cue.snapToLines || cue.line >= 0 && cue.line <= 100)) { + return cue.line; + } + + if (!cue.track || !cue.track.textTrackList || !cue.track.textTrackList.mediaElement) { + return -1; + } + + var track = cue.track, + trackList = track.textTrackList, + count = 0; + + for (var i = 0; i < trackList.length && trackList[i] !== track; i++) { + if (trackList[i].mode === "showing") { + count++; + } + } + + return ++count * -1; + } + + function StyleBox() {} // Apply styles to a div. If there is no div passed then it defaults to the + // div on 'this'. + + + StyleBox.prototype.applyStyles = function (styles, div) { + div = div || this.div; + + for (var prop in styles) { + if (styles.hasOwnProperty(prop)) { + div.style[prop] = styles[prop]; + } + } + }; + + StyleBox.prototype.formatStyle = function (val, unit) { + return val === 0 ? 0 : val + unit; + }; // Constructs the computed display state of the cue (a div). Places the div + // into the overlay which should be a block level element (usually a div). + + + function CueStyleBox(window, cue, styleOptions) { + StyleBox.call(this); + this.cue = cue; // Parse our cue's text into a DOM tree rooted at 'cueDiv'. This div will + // have inline positioning and will function as the cue background box. + + this.cueDiv = parseContent(window, cue.text); + var styles = { + color: "rgba(255, 255, 255, 1)", + backgroundColor: "rgba(0, 0, 0, 0.8)", + position: "relative", + left: 0, + right: 0, + top: 0, + bottom: 0, + display: "inline", + writingMode: cue.vertical === "" ? "horizontal-tb" : cue.vertical === "lr" ? "vertical-lr" : "vertical-rl", + unicodeBidi: "plaintext" + }; + this.applyStyles(styles, this.cueDiv); // Create an absolutely positioned div that will be used to position the cue + // div. Note, all WebVTT cue-setting alignments are equivalent to the CSS + // mirrors of them except middle instead of center on Safari. + + this.div = window.document.createElement("div"); + styles = { + direction: determineBidi(this.cueDiv), + writingMode: cue.vertical === "" ? "horizontal-tb" : cue.vertical === "lr" ? "vertical-lr" : "vertical-rl", + unicodeBidi: "plaintext", + textAlign: cue.align === "middle" ? "center" : cue.align, + font: styleOptions.font, + whiteSpace: "pre-line", + position: "absolute" + }; + this.applyStyles(styles); + this.div.appendChild(this.cueDiv); // Calculate the distance from the reference edge of the viewport to the text + // position of the cue box. The reference edge will be resolved later when + // the box orientation styles are applied. + + var textPos = 0; + + switch (cue.positionAlign) { + case "start": + textPos = cue.position; + break; + + case "center": + textPos = cue.position - cue.size / 2; + break; + + case "end": + textPos = cue.position - cue.size; + break; + } // Horizontal box orientation; textPos is the distance from the left edge of the + // area to the left edge of the box and cue.size is the distance extending to + // the right from there. + + + if (cue.vertical === "") { + this.applyStyles({ + left: this.formatStyle(textPos, "%"), + width: this.formatStyle(cue.size, "%") + }); // Vertical box orientation; textPos is the distance from the top edge of the + // area to the top edge of the box and cue.size is the height extending + // downwards from there. + } else { + this.applyStyles({ + top: this.formatStyle(textPos, "%"), + height: this.formatStyle(cue.size, "%") + }); + } + + this.move = function (box) { + this.applyStyles({ + top: this.formatStyle(box.top, "px"), + bottom: this.formatStyle(box.bottom, "px"), + left: this.formatStyle(box.left, "px"), + right: this.formatStyle(box.right, "px"), + height: this.formatStyle(box.height, "px"), + width: this.formatStyle(box.width, "px") + }); + }; + } + + CueStyleBox.prototype = _objCreate(StyleBox.prototype); + CueStyleBox.prototype.constructor = CueStyleBox; // Represents the co-ordinates of an Element in a way that we can easily + // compute things with such as if it overlaps or intersects with another Element. + // Can initialize it with either a StyleBox or another BoxPosition. + + function BoxPosition(obj) { + // Either a BoxPosition was passed in and we need to copy it, or a StyleBox + // was passed in and we need to copy the results of 'getBoundingClientRect' + // as the object returned is readonly. All co-ordinate values are in reference + // to the viewport origin (top left). + var lh, height, width, top; + + if (obj.div) { + height = obj.div.offsetHeight; + width = obj.div.offsetWidth; + top = obj.div.offsetTop; + var rects = (rects = obj.div.childNodes) && (rects = rects[0]) && rects.getClientRects && rects.getClientRects(); + obj = obj.div.getBoundingClientRect(); // In certain cases the outter div will be slightly larger then the sum of + // the inner div's lines. This could be due to bold text, etc, on some platforms. + // In this case we should get the average line height and use that. This will + // result in the desired behaviour. + + lh = rects ? Math.max(rects[0] && rects[0].height || 0, obj.height / rects.length) : 0; + } + + this.left = obj.left; + this.right = obj.right; + this.top = obj.top || top; + this.height = obj.height || height; + this.bottom = obj.bottom || top + (obj.height || height); + this.width = obj.width || width; + this.lineHeight = lh !== undefined ? lh : obj.lineHeight; + } // Move the box along a particular axis. Optionally pass in an amount to move + // the box. If no amount is passed then the default is the line height of the + // box. + + + BoxPosition.prototype.move = function (axis, toMove) { + toMove = toMove !== undefined ? toMove : this.lineHeight; + + switch (axis) { + case "+x": + this.left += toMove; + this.right += toMove; + break; + + case "-x": + this.left -= toMove; + this.right -= toMove; + break; + + case "+y": + this.top += toMove; + this.bottom += toMove; + break; + + case "-y": + this.top -= toMove; + this.bottom -= toMove; + break; + } + }; // Check if this box overlaps another box, b2. + + + BoxPosition.prototype.overlaps = function (b2) { + return this.left < b2.right && this.right > b2.left && this.top < b2.bottom && this.bottom > b2.top; + }; // Check if this box overlaps any other boxes in boxes. + + + BoxPosition.prototype.overlapsAny = function (boxes) { + for (var i = 0; i < boxes.length; i++) { + if (this.overlaps(boxes[i])) { + return true; + } + } + + return false; + }; // Check if this box is within another box. + + + BoxPosition.prototype.within = function (container) { + return this.top >= container.top && this.bottom <= container.bottom && this.left >= container.left && this.right <= container.right; + }; // Check if this box is entirely within the container or it is overlapping + // on the edge opposite of the axis direction passed. For example, if "+x" is + // passed and the box is overlapping on the left edge of the container, then + // return true. + + + BoxPosition.prototype.overlapsOppositeAxis = function (container, axis) { + switch (axis) { + case "+x": + return this.left < container.left; + + case "-x": + return this.right > container.right; + + case "+y": + return this.top < container.top; + + case "-y": + return this.bottom > container.bottom; + } + }; // Find the percentage of the area that this box is overlapping with another + // box. + + + BoxPosition.prototype.intersectPercentage = function (b2) { + var x = Math.max(0, Math.min(this.right, b2.right) - Math.max(this.left, b2.left)), + y = Math.max(0, Math.min(this.bottom, b2.bottom) - Math.max(this.top, b2.top)), + intersectArea = x * y; + return intersectArea / (this.height * this.width); + }; // Convert the positions from this box to CSS compatible positions using + // the reference container's positions. This has to be done because this + // box's positions are in reference to the viewport origin, whereas, CSS + // values are in referecne to their respective edges. + + + BoxPosition.prototype.toCSSCompatValues = function (reference) { + return { + top: this.top - reference.top, + bottom: reference.bottom - this.bottom, + left: this.left - reference.left, + right: reference.right - this.right, + height: this.height, + width: this.width + }; + }; // Get an object that represents the box's position without anything extra. + // Can pass a StyleBox, HTMLElement, or another BoxPositon. + + + BoxPosition.getSimpleBoxPosition = function (obj) { + var height = obj.div ? obj.div.offsetHeight : obj.tagName ? obj.offsetHeight : 0; + var width = obj.div ? obj.div.offsetWidth : obj.tagName ? obj.offsetWidth : 0; + var top = obj.div ? obj.div.offsetTop : obj.tagName ? obj.offsetTop : 0; + obj = obj.div ? obj.div.getBoundingClientRect() : obj.tagName ? obj.getBoundingClientRect() : obj; + var ret = { + left: obj.left, + right: obj.right, + top: obj.top || top, + height: obj.height || height, + bottom: obj.bottom || top + (obj.height || height), + width: obj.width || width + }; + return ret; + }; // Move a StyleBox to its specified, or next best, position. The containerBox + // is the box that contains the StyleBox, such as a div. boxPositions are + // a list of other boxes that the styleBox can't overlap with. + + + function moveBoxToLinePosition(window, styleBox, containerBox, boxPositions) { + // Find the best position for a cue box, b, on the video. The axis parameter + // is a list of axis, the order of which, it will move the box along. For example: + // Passing ["+x", "-x"] will move the box first along the x axis in the positive + // direction. If it doesn't find a good position for it there it will then move + // it along the x axis in the negative direction. + function findBestPosition(b, axis) { + var bestPosition, + specifiedPosition = new BoxPosition(b), + percentage = 1; // Highest possible so the first thing we get is better. + + for (var i = 0; i < axis.length; i++) { + while (b.overlapsOppositeAxis(containerBox, axis[i]) || b.within(containerBox) && b.overlapsAny(boxPositions)) { + b.move(axis[i]); + } // We found a spot where we aren't overlapping anything. This is our + // best position. + + + if (b.within(containerBox)) { + return b; + } + + var p = b.intersectPercentage(containerBox); // If we're outside the container box less then we were on our last try + // then remember this position as the best position. + + if (percentage > p) { + bestPosition = new BoxPosition(b); + percentage = p; + } // Reset the box position to the specified position. + + + b = new BoxPosition(specifiedPosition); + } + + return bestPosition || specifiedPosition; + } + + var boxPosition = new BoxPosition(styleBox), + cue = styleBox.cue, + linePos = computeLinePos(cue), + axis = []; // If we have a line number to align the cue to. + + if (cue.snapToLines) { + var size; + + switch (cue.vertical) { + case "": + axis = ["+y", "-y"]; + size = "height"; + break; + + case "rl": + axis = ["+x", "-x"]; + size = "width"; + break; + + case "lr": + axis = ["-x", "+x"]; + size = "width"; + break; + } + + var step = boxPosition.lineHeight, + position = step * Math.round(linePos), + maxPosition = containerBox[size] + step, + initialAxis = axis[0]; // If the specified intial position is greater then the max position then + // clamp the box to the amount of steps it would take for the box to + // reach the max position. + + if (Math.abs(position) > maxPosition) { + position = position < 0 ? -1 : 1; + position *= Math.ceil(maxPosition / step) * step; + } // If computed line position returns negative then line numbers are + // relative to the bottom of the video instead of the top. Therefore, we + // need to increase our initial position by the length or width of the + // video, depending on the writing direction, and reverse our axis directions. + + + if (linePos < 0) { + position += cue.vertical === "" ? containerBox.height : containerBox.width; + axis = axis.reverse(); + } // Move the box to the specified position. This may not be its best + // position. + + + boxPosition.move(initialAxis, position); + } else { + // If we have a percentage line value for the cue. + var calculatedPercentage = boxPosition.lineHeight / containerBox.height * 100; + + switch (cue.lineAlign) { + case "center": + linePos -= calculatedPercentage / 2; + break; + + case "end": + linePos -= calculatedPercentage; + break; + } // Apply initial line position to the cue box. + + + switch (cue.vertical) { + case "": + styleBox.applyStyles({ + top: styleBox.formatStyle(linePos, "%") + }); + break; + + case "rl": + styleBox.applyStyles({ + left: styleBox.formatStyle(linePos, "%") + }); + break; + + case "lr": + styleBox.applyStyles({ + right: styleBox.formatStyle(linePos, "%") + }); + break; + } + + axis = ["+y", "-x", "+x", "-y"]; // Get the box position again after we've applied the specified positioning + // to it. + + boxPosition = new BoxPosition(styleBox); + } + + var bestPosition = findBestPosition(boxPosition, axis); + styleBox.move(bestPosition.toCSSCompatValues(containerBox)); + } + + function WebVTT$1() {} // Nothing + // Helper to allow strings to be decoded instead of the default binary utf8 data. + + + WebVTT$1.StringDecoder = function () { + return { + decode: function decode(data) { + if (!data) { + return ""; + } + + if (typeof data !== "string") { + throw new Error("Error - expected string data."); + } + + return decodeURIComponent(encodeURIComponent(data)); + } + }; + }; + + WebVTT$1.convertCueToDOMTree = function (window, cuetext) { + if (!window || !cuetext) { + return null; + } + + return parseContent(window, cuetext); + }; + + var FONT_SIZE_PERCENT = 0.05; + var FONT_STYLE = "sans-serif"; + var CUE_BACKGROUND_PADDING = "1.5%"; // Runs the processing model over the cues and regions passed to it. + // @param overlay A block level element (usually a div) that the computed cues + // and regions will be placed into. + + WebVTT$1.processCues = function (window, cues, overlay) { + if (!window || !cues || !overlay) { + return null; + } // Remove all previous children. + + + while (overlay.firstChild) { + overlay.removeChild(overlay.firstChild); + } + + var paddedOverlay = window.document.createElement("div"); + paddedOverlay.style.position = "absolute"; + paddedOverlay.style.left = "0"; + paddedOverlay.style.right = "0"; + paddedOverlay.style.top = "0"; + paddedOverlay.style.bottom = "0"; + paddedOverlay.style.margin = CUE_BACKGROUND_PADDING; + overlay.appendChild(paddedOverlay); // Determine if we need to compute the display states of the cues. This could + // be the case if a cue's state has been changed since the last computation or + // if it has not been computed yet. + + function shouldCompute(cues) { + for (var i = 0; i < cues.length; i++) { + if (cues[i].hasBeenReset || !cues[i].displayState) { + return true; + } + } + + return false; + } // We don't need to recompute the cues' display states. Just reuse them. + + + if (!shouldCompute(cues)) { + for (var i = 0; i < cues.length; i++) { + paddedOverlay.appendChild(cues[i].displayState); + } + + return; + } + + var boxPositions = [], + containerBox = BoxPosition.getSimpleBoxPosition(paddedOverlay), + fontSize = Math.round(containerBox.height * FONT_SIZE_PERCENT * 100) / 100; + var styleOptions = { + font: fontSize + "px " + FONT_STYLE + }; + + (function () { + var styleBox, cue; + + for (var i = 0; i < cues.length; i++) { + cue = cues[i]; // Compute the intial position and styles of the cue div. + + styleBox = new CueStyleBox(window, cue, styleOptions); + paddedOverlay.appendChild(styleBox.div); // Move the cue div to it's correct line position. + + moveBoxToLinePosition(window, styleBox, containerBox, boxPositions); // Remember the computed div so that we don't have to recompute it later + // if we don't have too. + + cue.displayState = styleBox.div; + boxPositions.push(BoxPosition.getSimpleBoxPosition(styleBox)); + } + })(); + }; + + WebVTT$1.Parser = function (window, vttjs, decoder) { + if (!decoder) { + decoder = vttjs; + vttjs = {}; + } + + if (!vttjs) { + vttjs = {}; + } + + this.window = window; + this.vttjs = vttjs; + this.state = "INITIAL"; + this.buffer = ""; + this.decoder = decoder || new TextDecoder("utf8"); + this.regionList = []; + }; + + WebVTT$1.Parser.prototype = { + // If the error is a ParsingError then report it to the consumer if + // possible. If it's not a ParsingError then throw it like normal. + reportOrThrowError: function reportOrThrowError(e) { + if (e instanceof ParsingError) { + this.onparsingerror && this.onparsingerror(e); + } else { + throw e; + } + }, + parse: function parse(data) { + var self = this; // If there is no data then we won't decode it, but will just try to parse + // whatever is in buffer already. This may occur in circumstances, for + // example when flush() is called. + + if (data) { + // Try to decode the data that we received. + self.buffer += self.decoder.decode(data, { + stream: true + }); + } + + function collectNextLine() { + var buffer = self.buffer; + var pos = 0; + + while (pos < buffer.length && buffer[pos] !== '\r' && buffer[pos] !== '\n') { + ++pos; + } + + var line = buffer.substr(0, pos); // Advance the buffer early in case we fail below. + + if (buffer[pos] === '\r') { + ++pos; + } + + if (buffer[pos] === '\n') { + ++pos; + } + + self.buffer = buffer.substr(pos); + return line; + } // 3.4 WebVTT region and WebVTT region settings syntax + + + function parseRegion(input) { + var settings = new Settings(); + parseOptions(input, function (k, v) { + switch (k) { + case "id": + settings.set(k, v); + break; + + case "width": + settings.percent(k, v); + break; + + case "lines": + settings.integer(k, v); + break; + + case "regionanchor": + case "viewportanchor": + var xy = v.split(','); + + if (xy.length !== 2) { + break; + } // We have to make sure both x and y parse, so use a temporary + // settings object here. + + + var anchor = new Settings(); + anchor.percent("x", xy[0]); + anchor.percent("y", xy[1]); + + if (!anchor.has("x") || !anchor.has("y")) { + break; + } + + settings.set(k + "X", anchor.get("x")); + settings.set(k + "Y", anchor.get("y")); + break; + + case "scroll": + settings.alt(k, v, ["up"]); + break; + } + }, /=/, /\s/); // Create the region, using default values for any values that were not + // specified. + + if (settings.has("id")) { + var region = new (self.vttjs.VTTRegion || self.window.VTTRegion)(); + region.width = settings.get("width", 100); + region.lines = settings.get("lines", 3); + region.regionAnchorX = settings.get("regionanchorX", 0); + region.regionAnchorY = settings.get("regionanchorY", 100); + region.viewportAnchorX = settings.get("viewportanchorX", 0); + region.viewportAnchorY = settings.get("viewportanchorY", 100); + region.scroll = settings.get("scroll", ""); // Register the region. + + self.onregion && self.onregion(region); // Remember the VTTRegion for later in case we parse any VTTCues that + // reference it. + + self.regionList.push({ + id: settings.get("id"), + region: region + }); + } + } // draft-pantos-http-live-streaming-20 + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-20#section-3.5 + // 3.5 WebVTT + + + function parseTimestampMap(input) { + var settings = new Settings(); + parseOptions(input, function (k, v) { + switch (k) { + case "MPEGT": + settings.integer(k + 'S', v); + break; + + case "LOCA": + settings.set(k + 'L', parseTimeStamp(v)); + break; + } + }, /[^\d]:/, /,/); + self.ontimestampmap && self.ontimestampmap({ + "MPEGTS": settings.get("MPEGTS"), + "LOCAL": settings.get("LOCAL") + }); + } // 3.2 WebVTT metadata header syntax + + + function parseHeader(input) { + if (input.match(/X-TIMESTAMP-MAP/)) { + // This line contains HLS X-TIMESTAMP-MAP metadata + parseOptions(input, function (k, v) { + switch (k) { + case "X-TIMESTAMP-MAP": + parseTimestampMap(v); + break; + } + }, /=/); + } else { + parseOptions(input, function (k, v) { + switch (k) { + case "Region": + // 3.3 WebVTT region metadata header syntax + parseRegion(v); + break; + } + }, /:/); + } + } // 5.1 WebVTT file parsing. + + + try { + var line; + + if (self.state === "INITIAL") { + // We can't start parsing until we have the first line. + if (!/\r\n|\n/.test(self.buffer)) { + return this; + } + + line = collectNextLine(); + var m = line.match(/^WEBVTT([ \t].*)?$/); + + if (!m || !m[0]) { + throw new ParsingError(ParsingError.Errors.BadSignature); + } + + self.state = "HEADER"; + } + + var alreadyCollectedLine = false; + + while (self.buffer) { + // We can't parse a line until we have the full line. + if (!/\r\n|\n/.test(self.buffer)) { + return this; + } + + if (!alreadyCollectedLine) { + line = collectNextLine(); + } else { + alreadyCollectedLine = false; + } + + switch (self.state) { + case "HEADER": + // 13-18 - Allow a header (metadata) under the WEBVTT line. + if (/:/.test(line)) { + parseHeader(line); + } else if (!line) { + // An empty line terminates the header and starts the body (cues). + self.state = "ID"; + } + + continue; + + case "NOTE": + // Ignore NOTE blocks. + if (!line) { + self.state = "ID"; + } + + continue; + + case "ID": + // Check for the start of NOTE blocks. + if (/^NOTE($|[ \t])/.test(line)) { + self.state = "NOTE"; + break; + } // 19-29 - Allow any number of line terminators, then initialize new cue values. + + + if (!line) { + continue; + } + + self.cue = new (self.vttjs.VTTCue || self.window.VTTCue)(0, 0, ""); // Safari still uses the old middle value and won't accept center + + try { + self.cue.align = "center"; + } catch (e) { + self.cue.align = "middle"; + } + + self.state = "CUE"; // 30-39 - Check if self line contains an optional identifier or timing data. + + if (line.indexOf("-->") === -1) { + self.cue.id = line; + continue; + } + + // Process line as start of a cue. + + /*falls through*/ + + case "CUE": + // 40 - Collect cue timings and settings. + try { + parseCue(line, self.cue, self.regionList); + } catch (e) { + self.reportOrThrowError(e); // In case of an error ignore rest of the cue. + + self.cue = null; + self.state = "BADCUE"; + continue; + } + + self.state = "CUETEXT"; + continue; + + case "CUETEXT": + var hasSubstring = line.indexOf("-->") !== -1; // 34 - If we have an empty line then report the cue. + // 35 - If we have the special substring '-->' then report the cue, + // but do not collect the line as we need to process the current + // one as a new cue. + + if (!line || hasSubstring && (alreadyCollectedLine = true)) { + // We are done parsing self cue. + self.oncue && self.oncue(self.cue); + self.cue = null; + self.state = "ID"; + continue; + } + + if (self.cue.text) { + self.cue.text += "\n"; + } + + self.cue.text += line.replace(/\u2028/g, '\n').replace(/u2029/g, '\n'); + continue; + + case "BADCUE": + // BADCUE + // 54-62 - Collect and discard the remaining cue. + if (!line) { + self.state = "ID"; + } + + continue; + } + } + } catch (e) { + self.reportOrThrowError(e); // If we are currently parsing a cue, report what we have. + + if (self.state === "CUETEXT" && self.cue && self.oncue) { + self.oncue(self.cue); + } + + self.cue = null; // Enter BADWEBVTT state if header was not parsed correctly otherwise + // another exception occurred so enter BADCUE state. + + self.state = self.state === "INITIAL" ? "BADWEBVTT" : "BADCUE"; + } + + return this; + }, + flush: function flush() { + var self = this; + + try { + // Finish decoding the stream. + self.buffer += self.decoder.decode(); // Synthesize the end of the current cue or region. + + if (self.cue || self.state === "HEADER") { + self.buffer += "\n\n"; + self.parse(); + } // If we've flushed, parsed, and we're still on the INITIAL state then + // that means we don't have enough of the stream to parse the first + // line. + + + if (self.state === "INITIAL") { + throw new ParsingError(ParsingError.Errors.BadSignature); + } + } catch (e) { + self.reportOrThrowError(e); + } + + self.onflush && self.onflush(); + return this; + } + }; + var vtt = WebVTT$1; + + /** + * Copyright 2013 vtt.js Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + var autoKeyword = "auto"; + var directionSetting = { + "": 1, + "lr": 1, + "rl": 1 + }; + var alignSetting = { + "start": 1, + "center": 1, + "end": 1, + "left": 1, + "right": 1, + "auto": 1, + "line-left": 1, + "line-right": 1 + }; + + function findDirectionSetting(value) { + if (typeof value !== "string") { + return false; + } + + var dir = directionSetting[value.toLowerCase()]; + return dir ? value.toLowerCase() : false; + } + + function findAlignSetting(value) { + if (typeof value !== "string") { + return false; + } + + var align = alignSetting[value.toLowerCase()]; + return align ? value.toLowerCase() : false; + } + + function VTTCue(startTime, endTime, text) { + /** + * Shim implementation specific properties. These properties are not in + * the spec. + */ + // Lets us know when the VTTCue's data has changed in such a way that we need + // to recompute its display state. This lets us compute its display state + // lazily. + this.hasBeenReset = false; + /** + * VTTCue and TextTrackCue properties + * http://dev.w3.org/html5/webvtt/#vttcue-interface + */ + + var _id = ""; + var _pauseOnExit = false; + var _startTime = startTime; + var _endTime = endTime; + var _text = text; + var _region = null; + var _vertical = ""; + var _snapToLines = true; + var _line = "auto"; + var _lineAlign = "start"; + var _position = "auto"; + var _positionAlign = "auto"; + var _size = 100; + var _align = "center"; + Object.defineProperties(this, { + "id": { + enumerable: true, + get: function get() { + return _id; + }, + set: function set(value) { + _id = "" + value; + } + }, + "pauseOnExit": { + enumerable: true, + get: function get() { + return _pauseOnExit; + }, + set: function set(value) { + _pauseOnExit = !!value; + } + }, + "startTime": { + enumerable: true, + get: function get() { + return _startTime; + }, + set: function set(value) { + if (typeof value !== "number") { + throw new TypeError("Start time must be set to a number."); + } + + _startTime = value; + this.hasBeenReset = true; + } + }, + "endTime": { + enumerable: true, + get: function get() { + return _endTime; + }, + set: function set(value) { + if (typeof value !== "number") { + throw new TypeError("End time must be set to a number."); + } + + _endTime = value; + this.hasBeenReset = true; + } + }, + "text": { + enumerable: true, + get: function get() { + return _text; + }, + set: function set(value) { + _text = "" + value; + this.hasBeenReset = true; + } + }, + "region": { + enumerable: true, + get: function get() { + return _region; + }, + set: function set(value) { + _region = value; + this.hasBeenReset = true; + } + }, + "vertical": { + enumerable: true, + get: function get() { + return _vertical; + }, + set: function set(value) { + var setting = findDirectionSetting(value); // Have to check for false because the setting an be an empty string. + + if (setting === false) { + throw new SyntaxError("Vertical: an invalid or illegal direction string was specified."); + } + + _vertical = setting; + this.hasBeenReset = true; + } + }, + "snapToLines": { + enumerable: true, + get: function get() { + return _snapToLines; + }, + set: function set(value) { + _snapToLines = !!value; + this.hasBeenReset = true; + } + }, + "line": { + enumerable: true, + get: function get() { + return _line; + }, + set: function set(value) { + if (typeof value !== "number" && value !== autoKeyword) { + throw new SyntaxError("Line: an invalid number or illegal string was specified."); + } + + _line = value; + this.hasBeenReset = true; + } + }, + "lineAlign": { + enumerable: true, + get: function get() { + return _lineAlign; + }, + set: function set(value) { + var setting = findAlignSetting(value); + + if (!setting) { + console.warn("lineAlign: an invalid or illegal string was specified."); + } else { + _lineAlign = setting; + this.hasBeenReset = true; + } + } + }, + "position": { + enumerable: true, + get: function get() { + return _position; + }, + set: function set(value) { + if (value < 0 || value > 100) { + throw new Error("Position must be between 0 and 100."); + } + + _position = value; + this.hasBeenReset = true; + } + }, + "positionAlign": { + enumerable: true, + get: function get() { + return _positionAlign; + }, + set: function set(value) { + var setting = findAlignSetting(value); + + if (!setting) { + console.warn("positionAlign: an invalid or illegal string was specified."); + } else { + _positionAlign = setting; + this.hasBeenReset = true; + } + } + }, + "size": { + enumerable: true, + get: function get() { + return _size; + }, + set: function set(value) { + if (value < 0 || value > 100) { + throw new Error("Size must be between 0 and 100."); + } + + _size = value; + this.hasBeenReset = true; + } + }, + "align": { + enumerable: true, + get: function get() { + return _align; + }, + set: function set(value) { + var setting = findAlignSetting(value); + + if (!setting) { + throw new SyntaxError("align: an invalid or illegal alignment string was specified."); + } + + _align = setting; + this.hasBeenReset = true; + } + } + }); + /** + * Other spec defined properties + */ + // http://www.whatwg.org/specs/web-apps/current-work/multipage/the-video-element.html#text-track-cue-display-state + + this.displayState = undefined; + } + /** + * VTTCue methods + */ + + + VTTCue.prototype.getCueAsHTML = function () { + // Assume WebVTT.convertCueToDOMTree is on the global. + return WebVTT.convertCueToDOMTree(window, this.text); + }; + + var vttcue = VTTCue; + + /** + * Copyright 2013 vtt.js Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + var scrollSetting = { + "": true, + "up": true + }; + + function findScrollSetting(value) { + if (typeof value !== "string") { + return false; + } + + var scroll = scrollSetting[value.toLowerCase()]; + return scroll ? value.toLowerCase() : false; + } + + function isValidPercentValue(value) { + return typeof value === "number" && value >= 0 && value <= 100; + } // VTTRegion shim http://dev.w3.org/html5/webvtt/#vttregion-interface + + + function VTTRegion() { + var _width = 100; + var _lines = 3; + var _regionAnchorX = 0; + var _regionAnchorY = 100; + var _viewportAnchorX = 0; + var _viewportAnchorY = 100; + var _scroll = ""; + Object.defineProperties(this, { + "width": { + enumerable: true, + get: function get() { + return _width; + }, + set: function set(value) { + if (!isValidPercentValue(value)) { + throw new Error("Width must be between 0 and 100."); + } + + _width = value; + } + }, + "lines": { + enumerable: true, + get: function get() { + return _lines; + }, + set: function set(value) { + if (typeof value !== "number") { + throw new TypeError("Lines must be set to a number."); + } + + _lines = value; + } + }, + "regionAnchorY": { + enumerable: true, + get: function get() { + return _regionAnchorY; + }, + set: function set(value) { + if (!isValidPercentValue(value)) { + throw new Error("RegionAnchorX must be between 0 and 100."); + } + + _regionAnchorY = value; + } + }, + "regionAnchorX": { + enumerable: true, + get: function get() { + return _regionAnchorX; + }, + set: function set(value) { + if (!isValidPercentValue(value)) { + throw new Error("RegionAnchorY must be between 0 and 100."); + } + + _regionAnchorX = value; + } + }, + "viewportAnchorY": { + enumerable: true, + get: function get() { + return _viewportAnchorY; + }, + set: function set(value) { + if (!isValidPercentValue(value)) { + throw new Error("ViewportAnchorY must be between 0 and 100."); + } + + _viewportAnchorY = value; + } + }, + "viewportAnchorX": { + enumerable: true, + get: function get() { + return _viewportAnchorX; + }, + set: function set(value) { + if (!isValidPercentValue(value)) { + throw new Error("ViewportAnchorX must be between 0 and 100."); + } + + _viewportAnchorX = value; + } + }, + "scroll": { + enumerable: true, + get: function get() { + return _scroll; + }, + set: function set(value) { + var setting = findScrollSetting(value); // Have to check for false as an empty string is a legal value. + + if (setting === false) { + console.warn("Scroll: an invalid or illegal string was specified."); + } else { + _scroll = setting; + } + } + } + }); + } + + var vttregion = VTTRegion; + + var browserIndex = createCommonjsModule(function (module) { + /** + * Copyright 2013 vtt.js Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Default exports for Node. Export the extended versions of VTTCue and + // VTTRegion in Node since we likely want the capability to convert back and + // forth between JSON. If we don't then it's not that big of a deal since we're + // off browser. + var vttjs = module.exports = { + WebVTT: vtt, + VTTCue: vttcue, + VTTRegion: vttregion + }; + window$1.vttjs = vttjs; + window$1.WebVTT = vttjs.WebVTT; + var cueShim = vttjs.VTTCue; + var regionShim = vttjs.VTTRegion; + var nativeVTTCue = window$1.VTTCue; + var nativeVTTRegion = window$1.VTTRegion; + + vttjs.shim = function () { + window$1.VTTCue = cueShim; + window$1.VTTRegion = regionShim; + }; + + vttjs.restore = function () { + window$1.VTTCue = nativeVTTCue; + window$1.VTTRegion = nativeVTTRegion; + }; + + if (!window$1.VTTCue) { + vttjs.shim(); + } + }); + var browserIndex_1 = browserIndex.WebVTT; + var browserIndex_2 = browserIndex.VTTCue; + var browserIndex_3 = browserIndex.VTTRegion; + + /** + * An Object containing a structure like: `{src: 'url', type: 'mimetype'}` or string + * that just contains the src url alone. + * * `var SourceObject = {src: 'http://ex.com/video.mp4', type: 'video/mp4'};` + * `var SourceString = 'http://example.com/some-video.mp4';` + * + * @typedef {Object|string} Tech~SourceObject + * + * @property {string} src + * The url to the source + * + * @property {string} type + * The mime type of the source + */ + + /** + * A function used by {@link Tech} to create a new {@link TextTrack}. + * + * @private + * + * @param {Tech} self + * An instance of the Tech class. + * + * @param {string} kind + * `TextTrack` kind (subtitles, captions, descriptions, chapters, or metadata) + * + * @param {string} [label] + * Label to identify the text track + * + * @param {string} [language] + * Two letter language abbreviation + * + * @param {Object} [options={}] + * An object with additional text track options + * + * @return {TextTrack} + * The text track that was created. + */ + + function createTrackHelper(self, kind, label, language, options) { + if (options === void 0) { + options = {}; + } + + var tracks = self.textTracks(); + options.kind = kind; + + if (label) { + options.label = label; + } + + if (language) { + options.language = language; + } + + options.tech = self; + var track = new ALL.text.TrackClass(options); + tracks.addTrack(track); + return track; + } + /** + * This is the base class for media playback technology controllers, such as + * {@link Flash} and {@link HTML5} + * + * @extends Component + */ + + + var Tech = + /*#__PURE__*/ + function (_Component) { + inheritsLoose(Tech, _Component); + + /** + * Create an instance of this Tech. + * + * @param {Object} [options] + * The key/value store of player options. + * + * @param {Component~ReadyCallback} ready + * Callback function to call when the `HTML5` Tech is ready. + */ + function Tech(options, ready) { + var _this; + + if (options === void 0) { + options = {}; + } + + if (ready === void 0) { + ready = function ready() {}; + } + + // we don't want the tech to report user activity automatically. + // This is done manually in addControlsListeners + options.reportTouchActivity = false; + _this = _Component.call(this, null, options, ready) || this; // keep track of whether the current source has played at all to + // implement a very limited played() + + _this.hasStarted_ = false; + + _this.on('playing', function () { + this.hasStarted_ = true; + }); + + _this.on('loadstart', function () { + this.hasStarted_ = false; + }); + + ALL.names.forEach(function (name) { + var props = ALL[name]; + + if (options && options[props.getterName]) { + _this[props.privateName] = options[props.getterName]; + } + }); // Manually track progress in cases where the browser/flash player doesn't report it. + + if (!_this.featuresProgressEvents) { + _this.manualProgressOn(); + } // Manually track timeupdates in cases where the browser/flash player doesn't report it. + + + if (!_this.featuresTimeupdateEvents) { + _this.manualTimeUpdatesOn(); + } + + ['Text', 'Audio', 'Video'].forEach(function (track) { + if (options["native" + track + "Tracks"] === false) { + _this["featuresNative" + track + "Tracks"] = false; + } + }); + + if (options.nativeCaptions === false || options.nativeTextTracks === false) { + _this.featuresNativeTextTracks = false; + } else if (options.nativeCaptions === true || options.nativeTextTracks === true) { + _this.featuresNativeTextTracks = true; + } + + if (!_this.featuresNativeTextTracks) { + _this.emulateTextTracks(); + } + + _this.preloadTextTracks = options.preloadTextTracks !== false; + _this.autoRemoteTextTracks_ = new ALL.text.ListClass(); + + _this.initTrackListeners(); // Turn on component tap events only if not using native controls + + + if (!options.nativeControlsForTouch) { + _this.emitTapEvents(); + } + + if (_this.constructor) { + _this.name_ = _this.constructor.name || 'Unknown Tech'; + } + + return _this; + } + /** + * A special function to trigger source set in a way that will allow player + * to re-trigger if the player or tech are not ready yet. + * + * @fires Tech#sourceset + * @param {string} src The source string at the time of the source changing. + */ + + + var _proto = Tech.prototype; + + _proto.triggerSourceset = function triggerSourceset(src) { + var _this2 = this; + + if (!this.isReady_) { + // on initial ready we have to trigger source set + // 1ms after ready so that player can watch for it. + this.one('ready', function () { + return _this2.setTimeout(function () { + return _this2.triggerSourceset(src); + }, 1); + }); + } + /** + * Fired when the source is set on the tech causing the media element + * to reload. + * + * @see {@link Player#event:sourceset} + * @event Tech#sourceset + * @type {EventTarget~Event} + */ + + + this.trigger({ + src: src, + type: 'sourceset' + }); + } + /* Fallbacks for unsupported event types + ================================================================================ */ + + /** + * Polyfill the `progress` event for browsers that don't support it natively. + * + * @see {@link Tech#trackProgress} + */ + ; + + _proto.manualProgressOn = function manualProgressOn() { + this.on('durationchange', this.onDurationChange); + this.manualProgress = true; // Trigger progress watching when a source begins loading + + this.one('ready', this.trackProgress); + } + /** + * Turn off the polyfill for `progress` events that was created in + * {@link Tech#manualProgressOn} + */ + ; + + _proto.manualProgressOff = function manualProgressOff() { + this.manualProgress = false; + this.stopTrackingProgress(); + this.off('durationchange', this.onDurationChange); + } + /** + * This is used to trigger a `progress` event when the buffered percent changes. It + * sets an interval function that will be called every 500 milliseconds to check if the + * buffer end percent has changed. + * + * > This function is called by {@link Tech#manualProgressOn} + * + * @param {EventTarget~Event} event + * The `ready` event that caused this to run. + * + * @listens Tech#ready + * @fires Tech#progress + */ + ; + + _proto.trackProgress = function trackProgress(event) { + this.stopTrackingProgress(); + this.progressInterval = this.setInterval(bind(this, function () { + // Don't trigger unless buffered amount is greater than last time + var numBufferedPercent = this.bufferedPercent(); + + if (this.bufferedPercent_ !== numBufferedPercent) { + /** + * See {@link Player#progress} + * + * @event Tech#progress + * @type {EventTarget~Event} + */ + this.trigger('progress'); + } + + this.bufferedPercent_ = numBufferedPercent; + + if (numBufferedPercent === 1) { + this.stopTrackingProgress(); + } + }), 500); + } + /** + * Update our internal duration on a `durationchange` event by calling + * {@link Tech#duration}. + * + * @param {EventTarget~Event} event + * The `durationchange` event that caused this to run. + * + * @listens Tech#durationchange + */ + ; + + _proto.onDurationChange = function onDurationChange(event) { + this.duration_ = this.duration(); + } + /** + * Get and create a `TimeRange` object for buffering. + * + * @return {TimeRange} + * The time range object that was created. + */ + ; + + _proto.buffered = function buffered() { + return createTimeRanges(0, 0); + } + /** + * Get the percentage of the current video that is currently buffered. + * + * @return {number} + * A number from 0 to 1 that represents the decimal percentage of the + * video that is buffered. + * + */ + ; + + _proto.bufferedPercent = function bufferedPercent$1() { + return bufferedPercent(this.buffered(), this.duration_); + } + /** + * Turn off the polyfill for `progress` events that was created in + * {@link Tech#manualProgressOn} + * Stop manually tracking progress events by clearing the interval that was set in + * {@link Tech#trackProgress}. + */ + ; + + _proto.stopTrackingProgress = function stopTrackingProgress() { + this.clearInterval(this.progressInterval); + } + /** + * Polyfill the `timeupdate` event for browsers that don't support it. + * + * @see {@link Tech#trackCurrentTime} + */ + ; + + _proto.manualTimeUpdatesOn = function manualTimeUpdatesOn() { + this.manualTimeUpdates = true; + this.on('play', this.trackCurrentTime); + this.on('pause', this.stopTrackingCurrentTime); + } + /** + * Turn off the polyfill for `timeupdate` events that was created in + * {@link Tech#manualTimeUpdatesOn} + */ + ; + + _proto.manualTimeUpdatesOff = function manualTimeUpdatesOff() { + this.manualTimeUpdates = false; + this.stopTrackingCurrentTime(); + this.off('play', this.trackCurrentTime); + this.off('pause', this.stopTrackingCurrentTime); + } + /** + * Sets up an interval function to track current time and trigger `timeupdate` every + * 250 milliseconds. + * + * @listens Tech#play + * @triggers Tech#timeupdate + */ + ; + + _proto.trackCurrentTime = function trackCurrentTime() { + if (this.currentTimeInterval) { + this.stopTrackingCurrentTime(); + } + + this.currentTimeInterval = this.setInterval(function () { + /** + * Triggered at an interval of 250ms to indicated that time is passing in the video. + * + * @event Tech#timeupdate + * @type {EventTarget~Event} + */ + this.trigger({ + type: 'timeupdate', + target: this, + manuallyTriggered: true + }); // 42 = 24 fps // 250 is what Webkit uses // FF uses 15 + }, 250); + } + /** + * Stop the interval function created in {@link Tech#trackCurrentTime} so that the + * `timeupdate` event is no longer triggered. + * + * @listens {Tech#pause} + */ + ; + + _proto.stopTrackingCurrentTime = function stopTrackingCurrentTime() { + this.clearInterval(this.currentTimeInterval); // #1002 - if the video ends right before the next timeupdate would happen, + // the progress bar won't make it all the way to the end + + this.trigger({ + type: 'timeupdate', + target: this, + manuallyTriggered: true + }); + } + /** + * Turn off all event polyfills, clear the `Tech`s {@link AudioTrackList}, + * {@link VideoTrackList}, and {@link TextTrackList}, and dispose of this Tech. + * + * @fires Component#dispose + */ + ; + + _proto.dispose = function dispose() { + // clear out all tracks because we can't reuse them between techs + this.clearTracks(NORMAL.names); // Turn off any manual progress or timeupdate tracking + + if (this.manualProgress) { + this.manualProgressOff(); + } + + if (this.manualTimeUpdates) { + this.manualTimeUpdatesOff(); + } + + _Component.prototype.dispose.call(this); + } + /** + * Clear out a single `TrackList` or an array of `TrackLists` given their names. + * + * > Note: Techs without source handlers should call this between sources for `video` + * & `audio` tracks. You don't want to use them between tracks! + * + * @param {string[]|string} types + * TrackList names to clear, valid names are `video`, `audio`, and + * `text`. + */ + ; + + _proto.clearTracks = function clearTracks(types) { + var _this3 = this; + + types = [].concat(types); // clear out all tracks because we can't reuse them between techs + + types.forEach(function (type) { + var list = _this3[type + "Tracks"]() || []; + var i = list.length; + + while (i--) { + var track = list[i]; + + if (type === 'text') { + _this3.removeRemoteTextTrack(track); + } + + list.removeTrack(track); + } + }); + } + /** + * Remove any TextTracks added via addRemoteTextTrack that are + * flagged for automatic garbage collection + */ + ; + + _proto.cleanupAutoTextTracks = function cleanupAutoTextTracks() { + var list = this.autoRemoteTextTracks_ || []; + var i = list.length; + + while (i--) { + var track = list[i]; + this.removeRemoteTextTrack(track); + } + } + /** + * Reset the tech, which will removes all sources and reset the internal readyState. + * + * @abstract + */ + ; + + _proto.reset = function reset() {} + /** + * Get or set an error on the Tech. + * + * @param {MediaError} [err] + * Error to set on the Tech + * + * @return {MediaError|null} + * The current error object on the tech, or null if there isn't one. + */ + ; + + _proto.error = function error(err) { + if (err !== undefined) { + this.error_ = new MediaError(err); + this.trigger('error'); + } + + return this.error_; + } + /** + * Returns the `TimeRange`s that have been played through for the current source. + * + * > NOTE: This implementation is incomplete. It does not track the played `TimeRange`. + * It only checks whether the source has played at all or not. + * + * @return {TimeRange} + * - A single time range if this video has played + * - An empty set of ranges if not. + */ + ; + + _proto.played = function played() { + if (this.hasStarted_) { + return createTimeRanges(0, 0); + } + + return createTimeRanges(); + } + /** + * Causes a manual time update to occur if {@link Tech#manualTimeUpdatesOn} was + * previously called. + * + * @fires Tech#timeupdate + */ + ; + + _proto.setCurrentTime = function setCurrentTime() { + // improve the accuracy of manual timeupdates + if (this.manualTimeUpdates) { + /** + * A manual `timeupdate` event. + * + * @event Tech#timeupdate + * @type {EventTarget~Event} + */ + this.trigger({ + type: 'timeupdate', + target: this, + manuallyTriggered: true + }); + } + } + /** + * Turn on listeners for {@link VideoTrackList}, {@link {AudioTrackList}, and + * {@link TextTrackList} events. + * + * This adds {@link EventTarget~EventListeners} for `addtrack`, and `removetrack`. + * + * @fires Tech#audiotrackchange + * @fires Tech#videotrackchange + * @fires Tech#texttrackchange + */ + ; + + _proto.initTrackListeners = function initTrackListeners() { + var _this4 = this; + + /** + * Triggered when tracks are added or removed on the Tech {@link AudioTrackList} + * + * @event Tech#audiotrackchange + * @type {EventTarget~Event} + */ + + /** + * Triggered when tracks are added or removed on the Tech {@link VideoTrackList} + * + * @event Tech#videotrackchange + * @type {EventTarget~Event} + */ + + /** + * Triggered when tracks are added or removed on the Tech {@link TextTrackList} + * + * @event Tech#texttrackchange + * @type {EventTarget~Event} + */ + NORMAL.names.forEach(function (name) { + var props = NORMAL[name]; + + var trackListChanges = function trackListChanges() { + _this4.trigger(name + "trackchange"); + }; + + var tracks = _this4[props.getterName](); + + tracks.addEventListener('removetrack', trackListChanges); + tracks.addEventListener('addtrack', trackListChanges); + + _this4.on('dispose', function () { + tracks.removeEventListener('removetrack', trackListChanges); + tracks.removeEventListener('addtrack', trackListChanges); + }); + }); + } + /** + * Emulate TextTracks using vtt.js if necessary + * + * @fires Tech#vttjsloaded + * @fires Tech#vttjserror + */ + ; + + _proto.addWebVttScript_ = function addWebVttScript_() { + var _this5 = this; + + if (window$1.WebVTT) { + return; + } // Initially, Tech.el_ is a child of a dummy-div wait until the Component system + // signals that the Tech is ready at which point Tech.el_ is part of the DOM + // before inserting the WebVTT script + + + if (document.body.contains(this.el())) { + // load via require if available and vtt.js script location was not passed in + // as an option. novtt builds will turn the above require call into an empty object + // which will cause this if check to always fail. + if (!this.options_['vtt.js'] && isPlain(browserIndex) && Object.keys(browserIndex).length > 0) { + this.trigger('vttjsloaded'); + return; + } // load vtt.js via the script location option or the cdn of no location was + // passed in + + + var script = document.createElement('script'); + script.src = this.options_['vtt.js'] || 'https://vjs.zencdn.net/vttjs/0.14.1/vtt.min.js'; + + script.onload = function () { + /** + * Fired when vtt.js is loaded. + * + * @event Tech#vttjsloaded + * @type {EventTarget~Event} + */ + _this5.trigger('vttjsloaded'); + }; + + script.onerror = function () { + /** + * Fired when vtt.js was not loaded due to an error + * + * @event Tech#vttjsloaded + * @type {EventTarget~Event} + */ + _this5.trigger('vttjserror'); + }; + + this.on('dispose', function () { + script.onload = null; + script.onerror = null; + }); // but have not loaded yet and we set it to true before the inject so that + // we don't overwrite the injected window.WebVTT if it loads right away + + window$1.WebVTT = true; + this.el().parentNode.appendChild(script); + } else { + this.ready(this.addWebVttScript_); + } + } + /** + * Emulate texttracks + * + */ + ; + + _proto.emulateTextTracks = function emulateTextTracks() { + var _this6 = this; + + var tracks = this.textTracks(); + var remoteTracks = this.remoteTextTracks(); + + var handleAddTrack = function handleAddTrack(e) { + return tracks.addTrack(e.track); + }; + + var handleRemoveTrack = function handleRemoveTrack(e) { + return tracks.removeTrack(e.track); + }; + + remoteTracks.on('addtrack', handleAddTrack); + remoteTracks.on('removetrack', handleRemoveTrack); + this.addWebVttScript_(); + + var updateDisplay = function updateDisplay() { + return _this6.trigger('texttrackchange'); + }; + + var textTracksChanges = function textTracksChanges() { + updateDisplay(); + + for (var i = 0; i < tracks.length; i++) { + var track = tracks[i]; + track.removeEventListener('cuechange', updateDisplay); + + if (track.mode === 'showing') { + track.addEventListener('cuechange', updateDisplay); + } + } + }; + + textTracksChanges(); + tracks.addEventListener('change', textTracksChanges); + tracks.addEventListener('addtrack', textTracksChanges); + tracks.addEventListener('removetrack', textTracksChanges); + this.on('dispose', function () { + remoteTracks.off('addtrack', handleAddTrack); + remoteTracks.off('removetrack', handleRemoveTrack); + tracks.removeEventListener('change', textTracksChanges); + tracks.removeEventListener('addtrack', textTracksChanges); + tracks.removeEventListener('removetrack', textTracksChanges); + + for (var i = 0; i < tracks.length; i++) { + var track = tracks[i]; + track.removeEventListener('cuechange', updateDisplay); + } + }); + } + /** + * Create and returns a remote {@link TextTrack} object. + * + * @param {string} kind + * `TextTrack` kind (subtitles, captions, descriptions, chapters, or metadata) + * + * @param {string} [label] + * Label to identify the text track + * + * @param {string} [language] + * Two letter language abbreviation + * + * @return {TextTrack} + * The TextTrack that gets created. + */ + ; + + _proto.addTextTrack = function addTextTrack(kind, label, language) { + if (!kind) { + throw new Error('TextTrack kind is required but was not provided'); + } + + return createTrackHelper(this, kind, label, language); + } + /** + * Create an emulated TextTrack for use by addRemoteTextTrack + * + * This is intended to be overridden by classes that inherit from + * Tech in order to create native or custom TextTracks. + * + * @param {Object} options + * The object should contain the options to initialize the TextTrack with. + * + * @param {string} [options.kind] + * `TextTrack` kind (subtitles, captions, descriptions, chapters, or metadata). + * + * @param {string} [options.label]. + * Label to identify the text track + * + * @param {string} [options.language] + * Two letter language abbreviation. + * + * @return {HTMLTrackElement} + * The track element that gets created. + */ + ; + + _proto.createRemoteTextTrack = function createRemoteTextTrack(options) { + var track = mergeOptions(options, { + tech: this + }); + return new REMOTE.remoteTextEl.TrackClass(track); + } + /** + * Creates a remote text track object and returns an html track element. + * + * > Note: This can be an emulated {@link HTMLTrackElement} or a native one. + * + * @param {Object} options + * See {@link Tech#createRemoteTextTrack} for more detailed properties. + * + * @param {boolean} [manualCleanup=true] + * - When false: the TextTrack will be automatically removed from the video + * element whenever the source changes + * - When True: The TextTrack will have to be cleaned up manually + * + * @return {HTMLTrackElement} + * An Html Track Element. + * + * @deprecated The default functionality for this function will be equivalent + * to "manualCleanup=false" in the future. The manualCleanup parameter will + * also be removed. + */ + ; + + _proto.addRemoteTextTrack = function addRemoteTextTrack(options, manualCleanup) { + var _this7 = this; + + if (options === void 0) { + options = {}; + } + + var htmlTrackElement = this.createRemoteTextTrack(options); + + if (manualCleanup !== true && manualCleanup !== false) { + // deprecation warning + log.warn('Calling addRemoteTextTrack without explicitly setting the "manualCleanup" parameter to `true` is deprecated and default to `false` in future version of video.js'); + manualCleanup = true; + } // store HTMLTrackElement and TextTrack to remote list + + + this.remoteTextTrackEls().addTrackElement_(htmlTrackElement); + this.remoteTextTracks().addTrack(htmlTrackElement.track); + + if (manualCleanup !== true) { + // create the TextTrackList if it doesn't exist + this.ready(function () { + return _this7.autoRemoteTextTracks_.addTrack(htmlTrackElement.track); + }); + } + + return htmlTrackElement; + } + /** + * Remove a remote text track from the remote `TextTrackList`. + * + * @param {TextTrack} track + * `TextTrack` to remove from the `TextTrackList` + */ + ; + + _proto.removeRemoteTextTrack = function removeRemoteTextTrack(track) { + var trackElement = this.remoteTextTrackEls().getTrackElementByTrack_(track); // remove HTMLTrackElement and TextTrack from remote list + + this.remoteTextTrackEls().removeTrackElement_(trackElement); + this.remoteTextTracks().removeTrack(track); + this.autoRemoteTextTracks_.removeTrack(track); + } + /** + * Gets available media playback quality metrics as specified by the W3C's Media + * Playback Quality API. + * + * @see [Spec]{@link https://wicg.github.io/media-playback-quality} + * + * @return {Object} + * An object with supported media playback quality metrics + * + * @abstract + */ + ; + + _proto.getVideoPlaybackQuality = function getVideoPlaybackQuality() { + return {}; + } + /** + * Attempt to create a floating video window always on top of other windows + * so that users may continue consuming media while they interact with other + * content sites, or applications on their device. + * + * @see [Spec]{@link https://wicg.github.io/picture-in-picture} + * + * @return {Promise|undefined} + * A promise with a Picture-in-Picture window if the browser supports + * Promises (or one was passed in as an option). It returns undefined + * otherwise. + * + * @abstract + */ + ; + + _proto.requestPictureInPicture = function requestPictureInPicture() { + var PromiseClass = this.options_.Promise || window$1.Promise; + + if (PromiseClass) { + return PromiseClass.reject(); + } + } + /** + * A method to set a poster from a `Tech`. + * + * @abstract + */ + ; + + _proto.setPoster = function setPoster() {} + /** + * A method to check for the presence of the 'playsinline'