mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
4815 lines
172 KiB
JavaScript
4815 lines
172 KiB
JavaScript
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 = '<span>Live-ready</span>';
|
||
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 = `
|
||
<div>Powered by VDO.Ninja • Low-latency P2P backbone intact • <span class="podcast-help-link" id="podcast-help-link" role="button" tabindex="0">Guide</span></div>
|
||
<div class="cloud-status">
|
||
<span id="podcast-cloud-drive">${this.cloud?.hasDriveAccess() ? 'Google Drive linked' : 'Drive link pending'}</span>
|
||
<span id="podcast-cloud-dropbox">${this.cloud?.hasDropboxAccess() ? 'Dropbox linked' : 'Dropbox link pending'}</span>
|
||
</div>
|
||
`;
|
||
|
||
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: `
|
||
<p>The Podcast Studio is a specialized interface for recording multi-track audio from remote guests.</p>
|
||
<ul>
|
||
<li><strong>Create a room</strong> — Enter a room name and optional password</li>
|
||
<li><strong>Share the invite link</strong> — Guests join via the generated link</li>
|
||
<li><strong>Hit Record</strong> — Each guest's audio is captured as a separate WAV file</li>
|
||
</ul>
|
||
<p>All audio is recorded locally in your browser — nothing is uploaded unless you link cloud storage.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Session Markers',
|
||
content: `
|
||
<p>Markers are cue points you can drop during recording to mark important moments.</p>
|
||
<ul>
|
||
<li><strong>Manual markers</strong> — Click "Marker" to drop a cue at the current time</li>
|
||
<li><strong>Auto sync markers</strong> — Dropped automatically ~1 second into recording for alignment</li>
|
||
<li><strong>Join sync markers</strong> — Created when a guest joins mid-recording</li>
|
||
</ul>
|
||
<p><strong>WAV cue points:</strong> Markers are embedded directly in the WAV files as standard cue chunks. Compatible with:</p>
|
||
<ul>
|
||
<li>Adobe Audition, Audacity, Reaper, Pro Tools</li>
|
||
<li>Most DAWs that support WAV cue/region markers</li>
|
||
</ul>
|
||
<p><strong>CSV export:</strong> Use "Export CSV" or "Copy CSV" to get markers in spreadsheet format for reference or importing into editors that don't read WAV cues.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Late Joiners & Reconnects',
|
||
content: `
|
||
<p>If a guest joins or reconnects while recording is in progress:</p>
|
||
<ul>
|
||
<li>Their audio is automatically added to the recording</li>
|
||
<li>A sync marker is dropped ~1 second after they join</li>
|
||
<li>Their track appears in the timeline with a "Late join" badge</li>
|
||
</ul>
|
||
<p><strong>Syncing in post:</strong> Each track's markers are adjusted relative to when that track started. Use the shared sync markers to align tracks in your editor.</p>
|
||
<p>Screen shares added mid-session are also captured if video recording is enabled.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Cloud Backup',
|
||
content: `
|
||
<p>Link Google Drive or Dropbox to automatically upload recordings.</p>
|
||
<p><strong>Google Drive:</strong></p>
|
||
<ul>
|
||
<li>Uploads complete files after recording stops</li>
|
||
<li>Files appear in a "VDO.Ninja Recordings" folder</li>
|
||
</ul>
|
||
<p><strong>Dropbox:</strong></p>
|
||
<ul>
|
||
<li>Supports chunked uploads for large files</li>
|
||
<li>More reliable for longer recordings</li>
|
||
<li>Can paste a token manually if popup is blocked</li>
|
||
</ul>
|
||
<p>Both services are optional — recordings are always available for local download.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Video Recording',
|
||
content: `
|
||
<p>The studio focuses on audio ISO recording, but video options exist:</p>
|
||
<ul>
|
||
<li><strong>Record Group</strong> — Opens a popup with the combined scene for screen recording</li>
|
||
<li><strong>Individual video ISOs</strong> — <a href="https://www.youtube.com/watch?v=s5shpEqLZbM" target="_blank" rel="noopener">See video guide ↗</a></li>
|
||
</ul>
|
||
<p>For individual video tracks, guests can use <code>&record</code> in their URL to self-record, or use the remote recording features in the classic VDO.Ninja interface.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Live Captions',
|
||
content: `
|
||
<p>VDO.Ninja supports real-time speech-to-text captions:</p>
|
||
<ul>
|
||
<li><strong>Enable captions</strong> — Add <code>&transcribe</code> to a guest's URL to enable browser-based speech recognition</li>
|
||
<li><strong>Display captions</strong> — Use <code>&showcc</code> on the viewer/scene URL to display incoming captions</li>
|
||
<li><strong>Overlay in OBS</strong> — Captions can be displayed as a text overlay in your stream</li>
|
||
</ul>
|
||
<p>Captions are processed locally in the browser using the Web Speech API — no third-party services required.</p>
|
||
`,
|
||
},
|
||
{
|
||
title: 'Tips & Troubleshooting',
|
||
content: `
|
||
<ul>
|
||
<li><strong>No audio?</strong> — Ensure guests have granted microphone permission</li>
|
||
<li><strong>Tracks missing?</strong> — Check that guests joined before hitting Record, or they'll appear as late joiners</li>
|
||
<li><strong>Large files?</strong> — Use Dropbox for chunked uploads, or download locally</li>
|
||
<li><strong>Browser support:</strong> — Chrome/Edge recommended. Firefox/Safari may have limitations</li>
|
||
</ul>
|
||
`,
|
||
},
|
||
];
|
||
|
||
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();
|