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) ? '
Continue without signing in ' : ''}
-
- `;
-
- 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.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 ?
- `
Request Access ` :
- '
Go Back '
- }
-
- `;
-
- 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.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) ? '
Continue without signing in ' : ''}
+
+ `;
+
+ 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.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 ?
+ `
Request Access ` :
+ '
Go Back '
+ }
+
+ `;
+
+ 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.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
-
-
-
Share this link to sync clipboards:
-
-
- Copy Link
-
-
-
-
-
- Shared Clipboard
- 0 characters
-
-
-
-
-
-
- Connecting...
-
-
-
-
- Copy Clipboard Content
-
-
-
-
- Clear
-
-
- Clear & Paste
-
-
-
-
-
ℹ️ 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
+
+
+
Share this link to sync clipboards:
+
+
+ Copy Link
+
+
+
+
+
+ Shared Clipboard
+ 0 characters
+
+
+
+
+
+
+ Connecting...
+
+
+
+
+ Copy Clipboard Content
+
+
+
+
+ Clear
+
+
+ Clear & Paste
+
+
+
+
+
ℹ️ 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)
-
- Open the podcast studio (`?studio=podcast`) and locate the Cloud Sync card.
- Click Link Google Drive . Google Identity Services opens a popup window.
- Pick the Google account that will own the uploads and approve the drive.file scope.
-
- 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).
-
-
-
- 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
-
- Open the podcast studio (`?studio=podcast`) and scroll to the Cloud Sync card.
- Click Link Dropbox . Allow the popup (make sure your browser isn’t blocking it).
-
- Sign in with the Dropbox account that should receive uploads and approve the requested scopes. The popup will
- close once the code exchange completes.
-
- The studio status should switch to “Dropbox linked. Recordings will upload automatically.”
-
-
- 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:
-
-
-
- Visit
- https://www.dropbox.com/developers/apps , open your scoped app, and click Generate access token .
-
- Copy the token, paste it into the Cloud Sync token box, then click Link Dropbox .
-
- You can also launch the studio with ?dropbox=YOUR_TOKEN; the textbox will populate automatically.
-
-
-
- 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)
+
+ Open the podcast studio (`?studio=podcast`) and locate the Cloud Sync card.
+ Click Link Google Drive . Google Identity Services opens a popup window.
+ Pick the Google account that will own the uploads and approve the drive.file scope.
+
+ 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).
+
+
+
+ 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
+
+ Open the podcast studio (`?studio=podcast`) and scroll to the Cloud Sync card.
+ Click Link Dropbox . Allow the popup (make sure your browser isn’t blocking it).
+
+ Sign in with the Dropbox account that should receive uploads and approve the requested scopes. The popup will
+ close once the code exchange completes.
+
+ The studio status should switch to “Dropbox linked. Recordings will upload automatically.”
+
+
+ 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:
+
+
+
+ Visit
+ https://www.dropbox.com/developers/apps , open your scoped app, and click Generate access token .
+
+ Copy the token, paste it into the Cloud Sync token box, then click Link Dropbox .
+
+ You can also launch the studio with ?dropbox=YOUR_TOKEN; the textbox will populate automatically.
+
+
+
+ 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
-
-
-
-
-
-
-
-
-
-
-
-
-
- 📖 Quick Start Guide
-
-
Getting Started:
-
- Enter a VDO.Ninja URL in the input field above
- Click "ADD IFRAME" to create a new instance
- Use the control panel to send commands
- Monitor responses in the log window
-
-
-
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📖 Quick Start Guide
+
+
Getting Started:
+
+ Enter a VDO.Ninja URL in the input field above
+ Click "ADD IFRAME" to create a new instance
+ Use the control panel to send commands
+ Monitor responses in the log window
+
+
+
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
-
-
-
-
- + Add Another URL
-
-
-
Layout:
-
- Over/Under
- Side by Side
- Picture in Picture
- Transparent Overlay
- Tabs
-
-
-
-
-
Start Mixer
-
Clear Saved Settings
-
-
-
-
-
- ⟲
-
-
-
-
+
+
+
+
+
+ iFrame Mixer
+
+
+
+
+
iFrame Mixer
+
+
+
+
+ + Add Another URL
+
+
+
Layout:
+
+ Over/Under
+ Side by Side
+ Picture in Picture
+ Transparent Overlay
+ Tabs
+
+
+
+
+
Start Mixer
+
Clear Saved Settings
+
+
+
+
+
+ ⟲
+
+
+
+
\ 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.
+
+ Create a room — Enter a room name and optional password
+ Share the invite link — Guests join via the generated link
+ Hit Record — Each guest's audio is captured as a separate WAV file
+
+ 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.
+
+ Manual markers — Click "Marker" to drop a cue at the current time
+ Auto sync markers — Dropped automatically ~1 second into recording for alignment
+ Join sync markers — Created when a guest joins mid-recording
+
+ WAV cue points: Markers are embedded directly in the WAV files as standard cue chunks. Compatible with:
+
+ Adobe Audition, Audacity, Reaper, Pro Tools
+ Most DAWs that support WAV cue/region markers
+
+ 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:
+
+ Their audio is automatically added to the recording
+ A sync marker is dropped ~1 second after they join
+ Their track appears in the timeline with a "Late join" badge
+
+ 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:
+
+ Uploads complete files after recording stops
+ Files appear in a "VDO.Ninja Recordings" folder
+
+ Dropbox:
+
+ Supports chunked uploads for large files
+ More reliable for longer recordings
+ Can paste a token manually if popup is blocked
+
+ 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:
+
+ Record Group — Opens a popup with the combined scene for screen recording
+ Individual video ISOs — See video guide ↗
+
+ 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:
+
+ Enable captions — Add &transcribe to a guest's URL to enable browser-based speech recognition
+ Display captions — Use &showcc on the viewer/scene URL to display incoming captions
+ Overlay in OBS — Captions can be displayed as a text overlay in your stream
+
+ Captions are processed locally in the browser using the Web Speech API — no third-party services required.
+ `,
+ },
+ {
+ title: 'Tips & Troubleshooting',
+ content: `
+
+ No audio? — Ensure guests have granted microphone permission
+ Tracks missing? — Check that guests joined before hitting Record, or they'll appear as late joiners
+ Large files? — Use Dropbox for chunked uploads, or download locally
+ Browser support: — Chrome/Edge recommended. Firefox/Safari may have limitations
+
+ `,
+ },
+ ];
+
+ 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();
-