mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
3172 lines
139 KiB
JavaScript
3172 lines
139 KiB
JavaScript
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
|
||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||
import { normalizeRoomId } from './chat-session.js';
|
||
import './shared/invite-links.js';
|
||
import { normalizeTabId, requestOriginPermission } from './host-access.js';
|
||
|
||
let pendingInviteRoomId = '';
|
||
let pendingInviteChatKey = '';
|
||
let pendingRoomCreation = false;
|
||
|
||
function normalizeChatKey(value) {
|
||
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||
}
|
||
|
||
const elements = {
|
||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||
contents: document.querySelectorAll('.tab-content'),
|
||
copyInvite: document.getElementById('copyInvite'),
|
||
targetTab: document.getElementById('targetTab'),
|
||
siteAccessNotice: document.getElementById('siteAccessNotice'),
|
||
siteAccessMessage: document.getElementById('siteAccessMessage'),
|
||
siteAccessRetry: document.getElementById('siteAccessRetry'),
|
||
forceSyncBtn: document.getElementById('forceSyncBtn'),
|
||
forceSyncMode: document.getElementById('forceSyncMode'),
|
||
peerList: document.getElementById('peerList'),
|
||
logList: document.getElementById('logList'),
|
||
clearLogs: document.getElementById('clearLogs'),
|
||
connDot: document.getElementById('connDot'),
|
||
connText: document.getElementById('connText'),
|
||
connInfo: document.getElementById('connInfo'),
|
||
connPing: document.getElementById('connPing'),
|
||
serverUrl: document.getElementById('serverUrl'),
|
||
serverOfficial: document.getElementById('serverOfficial'),
|
||
serverCustom: document.getElementById('serverCustom'),
|
||
roomId: document.getElementById('roomId'),
|
||
password: document.getElementById('password'),
|
||
username: document.getElementById('username'),
|
||
joinBtn: document.getElementById('joinBtn'),
|
||
leaveBtn: document.getElementById('leaveBtn'),
|
||
roomInfo: document.getElementById('roomInfo'),
|
||
inviteLink: document.getElementById('inviteLink'),
|
||
filterNoise: document.getElementById('filterNoise'),
|
||
regenId: document.getElementById('regenId'),
|
||
restartTourBtn: document.getElementById('restartTourBtn'),
|
||
lastActionCard: document.getElementById('lastActionCard'),
|
||
historyList: document.getElementById('historyList'),
|
||
copyLogs: document.getElementById('copyLogs'),
|
||
createRoomBtn: document.getElementById('createRoomBtn'),
|
||
publicRooms: document.getElementById('publicRooms'),
|
||
refreshRooms: document.getElementById('refreshRooms'),
|
||
roomError: document.getElementById('roomError'),
|
||
retryBtn: document.getElementById('retryBtn'),
|
||
sectionJoin: document.getElementById('section-join'),
|
||
sectionActive: document.getElementById('section-active'),
|
||
hostControlCard: document.getElementById('hostControlCard'),
|
||
hostRoleBadge: document.getElementById('hostRoleBadge'),
|
||
hostControlToggleRow: document.getElementById('hostControlToggleRow'),
|
||
hostControlToggle: document.getElementById('hostControlToggle'),
|
||
hostControlGuestNote: document.getElementById('hostControlGuestNote'),
|
||
hostControlCohostHint: document.getElementById('hostControlCohostHint'),
|
||
activeRoomId: document.getElementById('activeRoomId'),
|
||
activeServer: document.getElementById('activeServer'),
|
||
peerListSync: document.getElementById('peerListSync'),
|
||
videoDebug: document.getElementById('videoDebug'),
|
||
playBtn: document.getElementById('playBtn'),
|
||
pauseBtn: document.getElementById('pauseBtn'),
|
||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||
chatEnabled: document.getElementById('chatEnabled'),
|
||
chatNotifications: document.getElementById('chatNotifications'),
|
||
chatPosition: document.getElementById('chatPosition'),
|
||
chatSize: document.getElementById('chatSize'),
|
||
chatStartMode: document.getElementById('chatStartMode'),
|
||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||
browserNotifications: document.getElementById('browserNotifications'),
|
||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
|
||
langSelector: document.getElementById('langSelector'),
|
||
themeSelector: document.getElementById('themeSelector'),
|
||
paletteSelector: document.getElementById('paletteSelector'),
|
||
|
||
audioSettingsLink: document.getElementById('audioSettingsLink'),
|
||
settingsSupportLink: document.getElementById('settingsSupportLink'),
|
||
settingsReviewLink: document.getElementById('settingsReviewLink'),
|
||
settingsVersion: document.getElementById('settingsVersion'),
|
||
devSupportLink: document.getElementById('devSupportLink'),
|
||
devReviewLink: document.getElementById('devReviewLink'),
|
||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||
devToolsTabBtn: document.getElementById('devToolsTabBtn'),
|
||
remoteSeekBack: document.getElementById('remoteSeekBack'),
|
||
remoteSeekForward: document.getElementById('remoteSeekForward'),
|
||
remoteSeekFiveMin: document.getElementById('remoteSeekFiveMin')
|
||
};
|
||
|
||
let localPeerId = null;
|
||
let lastPeersJson = null;
|
||
let lastKnownPeers = [];
|
||
let isDevTabVisible = false;
|
||
let reconnectSlowMode = false;
|
||
let isProcessingConnection = false;
|
||
let joinBtnTimeout = null;
|
||
let forceSyncResetTimer = null;
|
||
let popupIntervals = [];
|
||
let populateTabsToken = null;
|
||
let errorToken = 0;
|
||
let forceSyncDone = false;
|
||
let connectionErrorTimer = null;
|
||
let pendingConnectionErrorMsg = null;
|
||
|
||
function devToolsEnabled() {
|
||
return elements.username && elements.username.value.trim() === 'KoalaDev';
|
||
}
|
||
|
||
function syncDevToolsVisibility() {
|
||
if (!elements.devToolsTabBtn) return;
|
||
const enabled = devToolsEnabled();
|
||
elements.devToolsTabBtn.style.display = enabled ? '' : 'none';
|
||
if (!enabled && document.getElementById('tab-devtools')?.classList.contains('active')) {
|
||
document.querySelector('.tab-btn[data-tab="tab-settings"]')?.click();
|
||
}
|
||
}
|
||
let roomListRefreshTimer = null;
|
||
let roomListRefreshInterval = null;
|
||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||
const FEATURE_HINTS = [
|
||
{
|
||
key: 'audio_processing',
|
||
selector: '#audioProcessingLabel',
|
||
position: 'beforebegin',
|
||
i18nTooltip: 'NEW_FEATURE_AUDIO'
|
||
},
|
||
{
|
||
key: 'theme_picker',
|
||
selector: '#paletteLabel',
|
||
position: 'afterend',
|
||
i18nTooltip: 'NEW_FEATURE_THEME'
|
||
}
|
||
];
|
||
|
||
// --- Helpers ---
|
||
function configureFooterLinks() {
|
||
const reviewUrl = getReviewUrl();
|
||
[elements.settingsSupportLink, elements.devSupportLink].forEach(link => {
|
||
if (link) link.href = SUPPORT_URL;
|
||
});
|
||
[elements.settingsReviewLink, elements.devReviewLink].forEach(link => {
|
||
if (link) link.href = reviewUrl;
|
||
});
|
||
}
|
||
|
||
function openAudioSettingsPage() {
|
||
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
|
||
}
|
||
|
||
function localizeThemeSelector(locale) {
|
||
const labels = {
|
||
de: ['Darstellung', 'System', 'Dunkel', 'Hell', 'Folge dem System-Design oder wähle ein festes Design'],
|
||
fr: ['Apparence', 'Système', 'Sombre', 'Clair', 'Suivre le thème du système ou choisir une apparence fixe'],
|
||
es: ['Apariencia', 'Sistema', 'Oscuro', 'Claro', 'Seguir el tema del sistema o elegir una apariencia fija'],
|
||
it: ['Aspetto', 'Sistema', 'Scuro', 'Chiaro', 'Segui il tema di sistema o scegli un aspetto fisso'],
|
||
nl: ['Weergave', 'Systeem', 'Donker', 'Licht', 'Volg het systeemthema of kies een vaste weergave'],
|
||
pl: ['Wygląd', 'System', 'Ciemny', 'Jasny', 'Dostosuj do motywu systemowego lub wybierz stały wygląd'],
|
||
pt: ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||
'pt-BR': ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||
tr: ['Görünüm', 'Sistem', 'Koyu', 'Açık', 'Sistem temasını takip et veya sabit bir görünüm seç'],
|
||
ru: ['Оформление', 'Системная', 'Тёмная', 'Светлая', 'Следовать системной теме или выбрать фиксированное оформление'],
|
||
ja: ['外観', 'システム', 'ダーク', 'ライト', 'システムテーマに従うか、固定の外観を選択します'],
|
||
ko: ['테마', '시스템', '다크', '라이트', '시스템 테마를 따르거나 고정된 테마를 선택하세요'],
|
||
zh: ['外观', '跟随系统', '深色', '浅色', '跟随系统主题或选择固定外观'],
|
||
uk: ['Вигляд', 'Системна', 'Темна', 'Світла', 'Дотримуватися системної теми або вибрати фіксоване оформлення'],
|
||
en: ['Appearance', 'System', 'Dark', 'Light', 'Follow the system theme or choose a fixed appearance']
|
||
};
|
||
const [label, system, dark, light, tooltip] = labels[locale] || labels.en;
|
||
const labelEl = document.getElementById('themeLabel');
|
||
const systemEl = document.getElementById('themeOptionSystem');
|
||
const darkEl = document.getElementById('themeOptionDark');
|
||
const lightEl = document.getElementById('themeOptionLight');
|
||
const systemButton = document.getElementById('themeChoiceSystem');
|
||
const darkButton = document.getElementById('themeChoiceDark');
|
||
const lightButton = document.getElementById('themeChoiceLight');
|
||
if (labelEl) {
|
||
labelEl.textContent = label;
|
||
if (tooltip) labelEl.setAttribute('title', tooltip);
|
||
}
|
||
if (systemEl) systemEl.textContent = system;
|
||
if (darkEl) darkEl.textContent = dark;
|
||
if (lightEl) lightEl.textContent = light;
|
||
if (systemButton) systemButton.textContent = system;
|
||
if (darkButton) darkButton.textContent = dark;
|
||
if (lightButton) lightButton.textContent = light;
|
||
if (elements.themeSelector) elements.themeSelector.setAttribute('aria-label', label);
|
||
|
||
// The palette scheme names (Eucalyptus / Cyber / Graphite) are brand
|
||
// words kept verbatim; only the row label is localized.
|
||
const paletteLabels = {
|
||
de: ['Farbschema', 'Wähle ein kuratiertes Farbschema für die Erweiterung'],
|
||
fr: ['Thème', 'Choisissez une palette de couleurs personnalisée pour l\'extension'],
|
||
es: ['Tema', 'Elige una paleta de colores seleccionada para la extensión'],
|
||
it: ['Tema', 'Scegli una combinazione di colori curata per l\'estensione'],
|
||
nl: ['Thema', 'Kies een samengesteld kleurenschema voor de extensie'],
|
||
pl: ['Motyw', 'Wybierz dopasowany schemat kolorów dla rozszerzenia'],
|
||
pt: ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||
'pt-BR': ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||
tr: ['Tema', 'Uzantı için özel olarak seçilmiş bir renk şeması seçin'],
|
||
ru: ['Тема', 'Выберите цветовую схему для расширения'],
|
||
ja: ['テーマ', '拡張機能の厳選された配色を選択します'],
|
||
ko: ['테마', '확장 프로그램에 맞는 특별한 색상 테마를 선택하세요'],
|
||
zh: ['主题', '为扩展程序选择精心设计的配色方案'],
|
||
uk: ['Тема', 'Виберіть колірну схему для розширення'],
|
||
en: ['Theme', 'Pick a curated colour scheme for the extension']
|
||
};
|
||
const [paletteLabel, paletteTooltip] = paletteLabels[locale] || paletteLabels.en;
|
||
const paletteLabelEl = document.getElementById('paletteLabel');
|
||
if (paletteLabelEl) {
|
||
paletteLabelEl.textContent = paletteLabel;
|
||
if (paletteTooltip) paletteLabelEl.setAttribute('title', paletteTooltip);
|
||
}
|
||
if (elements.paletteSelector) elements.paletteSelector.setAttribute('aria-label', paletteLabel);
|
||
|
||
// Micro-descriptors under each swatch card: [nature, classic, minimal].
|
||
const paletteDescs = {
|
||
de: ['Natur', 'Klassik', 'Minimal'], fr: ['Nature', 'Classique', 'Minimal'],
|
||
es: ['Naturaleza', 'Clásico', 'Minimal'], it: ['Natura', 'Classico', 'Minimal'],
|
||
nl: ['Natuur', 'Klassiek', 'Minimaal'], pl: ['Natura', 'Klasyczny', 'Minimal'],
|
||
pt: ['Natureza', 'Clássico', 'Minimal'], 'pt-BR': ['Natureza', 'Clássico', 'Minimal'],
|
||
tr: ['Doğa', 'Klasik', 'Minimal'], ru: ['Природа', 'Классика', 'Минимал'],
|
||
ja: ['ネイチャー', 'クラシック', 'ミニマル'], ko: ['자연', '클래식', '미니멀'],
|
||
zh: ['自然', '经典', '极简'], uk: ['Природа', 'Класика', 'Мінімал'],
|
||
en: ['Nature', 'Classic', 'Minimal']
|
||
};
|
||
const [natureDesc, classicDesc, minimalDesc] = paletteDescs[locale] || paletteDescs.en;
|
||
const descMap = { eucalyptus: natureDesc, cyber: classicDesc, graphite: minimalDesc };
|
||
document.querySelectorAll('.palette-choice').forEach(btn => {
|
||
const desc = btn.querySelector('.palette-desc');
|
||
if (desc) desc.textContent = descMap[btn.dataset.paletteValue] || desc.textContent;
|
||
});
|
||
}
|
||
|
||
function syncThemePicker() {
|
||
const activeMode = elements.themeSelector?.value || 'system';
|
||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||
const active = button.dataset.themeValue === activeMode;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
}
|
||
|
||
function syncPalettePicker() {
|
||
const activePalette = elements.paletteSelector?.value || 'eucalyptus';
|
||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||
const active = button.dataset.paletteValue === activePalette;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
}
|
||
|
||
// Briefly enable global colour transitions so a theme/appearance switch
|
||
// cross-fades instead of snapping. Self-clearing; a no-op under reduced motion.
|
||
let themeAnimTimer = null;
|
||
function flashThemeTransition() {
|
||
const root = document.documentElement;
|
||
root.classList.add('theme-anim');
|
||
if (themeAnimTimer) clearTimeout(themeAnimTimer);
|
||
themeAnimTimer = setTimeout(() => root.classList.remove('theme-anim'), 300);
|
||
}
|
||
|
||
async function updateFeatureHints() {
|
||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||
|
||
FEATURE_HINTS.forEach(feature => {
|
||
const target = document.querySelector(feature.selector);
|
||
if (!target) return;
|
||
|
||
const existing = target.parentElement?.querySelector(`.feature-hint[data-feature="${feature.key}"]`);
|
||
if (dismissed.includes(feature.key)) {
|
||
if (existing) existing.remove();
|
||
return;
|
||
}
|
||
|
||
if (!existing) {
|
||
const hint = document.createElement('span');
|
||
hint.className = 'feature-hint';
|
||
hint.dataset.feature = feature.key;
|
||
hint.title = getMessage(feature.i18nTooltip);
|
||
target.insertAdjacentElement(feature.position || 'afterend', hint);
|
||
} else {
|
||
existing.title = getMessage(feature.i18nTooltip);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function dismissFeatureHint(featureKey) {
|
||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||
if (!dismissed.includes(featureKey)) {
|
||
await chrome.storage.sync.set({ dismissedHints: [...dismissed, featureKey] });
|
||
}
|
||
await updateFeatureHints();
|
||
}
|
||
|
||
function clearConnectionErrorTimer() {
|
||
if (connectionErrorTimer) {
|
||
clearTimeout(connectionErrorTimer);
|
||
connectionErrorTimer = null;
|
||
}
|
||
pendingConnectionErrorMsg = null;
|
||
}
|
||
|
||
function setRoomRefreshCooldown() {
|
||
if (roomListRefreshTimer) clearTimeout(roomListRefreshTimer);
|
||
if (roomListRefreshInterval) clearInterval(roomListRefreshInterval);
|
||
const originalLabel = getMessage('BTN_REFRESH');
|
||
const updateLabel = () => {
|
||
const secondsLeft = Math.max(1, Math.ceil((cooldownEndsAt - Date.now()) / 1000));
|
||
elements.refreshRooms.textContent = getMessage('BTN_REFRESH_COOLDOWN', { seconds: secondsLeft });
|
||
elements.refreshRooms.title = getMessage('BTN_REFRESH_COOLDOWN_TOOLTIP', { seconds: secondsLeft });
|
||
};
|
||
|
||
const cooldownEndsAt = Date.now() + ROOM_LIST_REFRESH_COOLDOWN_MS;
|
||
elements.refreshRooms.disabled = true;
|
||
updateLabel();
|
||
|
||
roomListRefreshInterval = setInterval(updateLabel, 250);
|
||
roomListRefreshTimer = setTimeout(() => {
|
||
elements.refreshRooms.disabled = false;
|
||
elements.refreshRooms.textContent = originalLabel;
|
||
elements.refreshRooms.title = getMessage('BTN_REFRESH_TOOLTIP');
|
||
clearInterval(roomListRefreshInterval);
|
||
roomListRefreshInterval = null;
|
||
roomListRefreshTimer = null;
|
||
}, ROOM_LIST_REFRESH_COOLDOWN_MS);
|
||
}
|
||
|
||
// --- Initialization ---
|
||
async function init() {
|
||
// Local-only by design — settings and room credentials never come from
|
||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||
|
||
let activeLang = localData.locale;
|
||
if (!activeLang) {
|
||
activeLang = getSystemLanguage();
|
||
chrome.storage.local.set({ locale: activeLang });
|
||
}
|
||
|
||
await loadLocale(activeLang);
|
||
translateDOM();
|
||
localizeThemeSelector(activeLang);
|
||
|
||
if (elements.langSelector) elements.langSelector.value = activeLang;
|
||
if (elements.themeSelector) elements.themeSelector.value = ['dark', 'light'].includes(localData.themeMode) ? localData.themeMode : 'system';
|
||
if (elements.paletteSelector) elements.paletteSelector.value = ['cyber', 'graphite'].includes(localData.themePalette) ? localData.themePalette : 'eucalyptus';
|
||
syncThemePicker();
|
||
syncPalettePicker();
|
||
|
||
let username = localData.username;
|
||
if (!username) {
|
||
username = generateUsername();
|
||
await chrome.storage.local.set({ username });
|
||
}
|
||
|
||
elements.serverUrl.value = localData.serverUrl || '';
|
||
elements.roomId.value = normalizeRoomId(localData.roomId);
|
||
elements.password.value = localData.password || '';
|
||
elements.username.value = username;
|
||
syncDevToolsVisibility();
|
||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||
if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false;
|
||
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
|
||
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
|
||
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
|
||
syncChatSettingsState();
|
||
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
|
||
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
|
||
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
|
||
if (elements.mediaTitlePrivacyMode) elements.mediaTitlePrivacyMode.value = mediaTitlePrivacyMode;
|
||
if (elements.forceSyncMode) elements.forceSyncMode.value = localData.forceSyncMode || 'jump-to-others';
|
||
if (elements.browserNotifications) elements.browserNotifications.checked = localData.browserNotifications === true;
|
||
if (elements.autoCopyInvite) elements.autoCopyInvite.checked = localData.autoCopyInvite !== false;
|
||
|
||
// Set Version Info
|
||
const versionTxt = `v${chrome.runtime.getManifest().version}`;
|
||
const versionEl = document.getElementById('appVersion');
|
||
if (versionEl) versionEl.textContent = versionTxt;
|
||
const popupVerEl = document.getElementById('popupVersion');
|
||
if (popupVerEl) popupVerEl.textContent = versionTxt;
|
||
if (elements.settingsVersion) elements.settingsVersion.textContent = versionTxt;
|
||
configureFooterLinks();
|
||
await updateFeatureHints();
|
||
|
||
if (localData.useCustomServer) {
|
||
setServerMode(true);
|
||
} else {
|
||
setServerMode(false);
|
||
}
|
||
|
||
toggleUIState(!!localData.roomId);
|
||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
|
||
refreshLogs();
|
||
refreshHistory();
|
||
|
||
// Initial Status Check (status shows via GET_STATUS below)
|
||
|
||
// Initial Status Check
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS', retryPendingTarget: true }, async (res) => {
|
||
if (chrome.runtime.lastError) {
|
||
console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message);
|
||
await populateTabs();
|
||
return;
|
||
}
|
||
if (res) {
|
||
localPeerId = res.peerId;
|
||
reconnectSlowMode = res.reconnectSlowMode || false;
|
||
applyConnectionStatus(res.status);
|
||
updatePingDisplay(res.ping);
|
||
updatePeerList(res.peers);
|
||
lastKnownPeers = res.peers || [];
|
||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: res.status === 'connected' });
|
||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||
|
||
// If user has a room configured but background is not connected (disconnected or idle),
|
||
// trigger connection now — the popup opening is explicit user intent.
|
||
if ((res.status === 'disconnected' || res.status === 'idle') && localData.roomId) {
|
||
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
|
||
applyConnectionStatus('connecting');
|
||
}
|
||
|
||
if (res.pendingTargetTabId) {
|
||
showSiteAccessNotice(
|
||
res.pendingTargetHost,
|
||
res.pendingTargetOriginPattern,
|
||
res.pendingTargetTabId
|
||
);
|
||
} else {
|
||
hideSiteAccessNotice();
|
||
}
|
||
|
||
// Keep a denied selection visible while Chrome waits for the user
|
||
// to grant access; it becomes active automatically after approval.
|
||
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
|
||
|
||
// Render lobby status if active
|
||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||
|
||
if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) {
|
||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||
if (syncTabBtn) syncTabBtn.click();
|
||
showSelectVideoHint();
|
||
} else if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
|
||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||
if (btn) btn.click();
|
||
}
|
||
} else {
|
||
await populateTabs();
|
||
if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
|
||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||
if (btn) btn.click();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check for invite link on landing page
|
||
checkInviteLink();
|
||
|
||
// Initialize public rooms placeholder
|
||
renderEmpty(elements.publicRooms, 'rooms');
|
||
|
||
// Debug Info Refresh
|
||
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
|
||
|
||
// Show onboarding on first visit
|
||
chrome.storage.sync.get(['onboardingComplete'], (data) => {
|
||
if (!data.onboardingComplete) showOnboarding();
|
||
});
|
||
}
|
||
|
||
// --- UI Logic ---
|
||
function toggleUIState(inRoom) {
|
||
if (elements.sectionJoin) elements.sectionJoin.style.display = inRoom ? 'none' : 'block';
|
||
if (elements.sectionActive) elements.sectionActive.style.display = inRoom ? 'block' : 'none';
|
||
if (elements.peerListSync) elements.peerListSync.style.display = inRoom ? 'block' : 'none';
|
||
|
||
const syncActive = document.getElementById('sync-active');
|
||
const syncInactive = document.getElementById('sync-inactive');
|
||
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
|
||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||
}
|
||
|
||
// --- Host Control Mode UI ---
|
||
// True when we're a guest in a host-only room → remote-control buttons are locked.
|
||
let hcmGuestLocked = false;
|
||
let siteAccessBlocked = false;
|
||
let siteAccessHost = null;
|
||
let siteAccessOriginPattern = null;
|
||
let siteAccessTabId = null;
|
||
|
||
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
|
||
let hcmAmOwner = false;
|
||
let hcmControllers = [];
|
||
let hcmCoHostSupported = false;
|
||
let hcmOwnerPeerId = null;
|
||
let hcmRoomIsHostOnly = false;
|
||
|
||
function updateHostControlUI(state) {
|
||
const s = state || {};
|
||
const controlMode = s.controlMode;
|
||
const amHost = !!s.amHost;
|
||
const amController = !!s.amController;
|
||
const hostOnly = controlMode === 'host-only';
|
||
|
||
// Stash for the peer-list renderer, then force it to re-render with new roles.
|
||
hcmAmOwner = amHost;
|
||
hcmControllers = Array.isArray(s.controllers) ? s.controllers : [];
|
||
hcmCoHostSupported = !!s.coHostSupported;
|
||
hcmOwnerPeerId = s.hostPeerId || null;
|
||
hcmRoomIsHostOnly = hostOnly;
|
||
lastPeersJson = '';
|
||
if (activePeers) updatePeerList(activePeers);
|
||
|
||
const card = elements.hostControlCard;
|
||
if (!card) return;
|
||
// Explicit capability advertised by the relay. Against an older relay it's
|
||
// false/absent → feature unavailable, so hide the card instead of misleading UI.
|
||
const serverSupportsHostControl = !!s.hostControlSupported;
|
||
// Show the card only when meaningful: owner always (to manage); a non-controller
|
||
// guest only while host-only is active. A guest in a normal room sees nothing.
|
||
const show = s.inRoom && serverSupportsHostControl && (amHost || hostOnly);
|
||
if (!show) {
|
||
card.style.display = 'none';
|
||
hcmGuestLocked = false;
|
||
setRemoteControlsLocked(false);
|
||
return;
|
||
}
|
||
card.style.display = 'block';
|
||
if (elements.hostRoleBadge) {
|
||
const role = amHost ? (getMessage('BADGE_HOST') || 'Host')
|
||
: amController ? (getMessage('BADGE_CONTROLLER') || 'Controller')
|
||
: (getMessage('BADGE_GUEST') || 'Guest');
|
||
elements.hostRoleBadge.textContent = role;
|
||
elements.hostRoleBadge.style.background = (amHost || amController) ? 'var(--accent)' : 'var(--text-muted)';
|
||
}
|
||
if (elements.hostControlToggleRow) elements.hostControlToggleRow.style.display = amHost ? 'flex' : 'none';
|
||
if (elements.hostControlToggle) elements.hostControlToggle.checked = hostOnly;
|
||
if (elements.hostControlGuestNote) elements.hostControlGuestNote.style.display = (!amController && hostOnly) ? 'block' : 'none';
|
||
// Owner-only discoverability hint: co-hosting is granted down in the peer list,
|
||
// which isn't obvious from this card. Only show it when it's actionable — owner,
|
||
// relay supports co-host, and the room is locked to host-only (the buttons only
|
||
// render in that mode).
|
||
if (elements.hostControlCohostHint) elements.hostControlCohostHint.style.display = (amHost && !!s.coHostSupported && hostOnly) ? 'block' : 'none';
|
||
|
||
// Only a non-controller is locked out of the remote controls.
|
||
hcmGuestLocked = (!amController && hostOnly);
|
||
setRemoteControlsLocked(hcmGuestLocked);
|
||
}
|
||
|
||
function setRemoteControlsLocked(locked) {
|
||
const effectiveLocked = locked || siteAccessBlocked;
|
||
[elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => {
|
||
if (!btn) return;
|
||
btn.disabled = effectiveLocked;
|
||
btn.style.opacity = effectiveLocked ? '0.5' : '';
|
||
btn.style.cursor = effectiveLocked ? 'not-allowed' : '';
|
||
btn.title = siteAccessBlocked
|
||
? (elements.siteAccessMessage?.textContent || getMessage('ERR_NO_VIDEO_TAB'))
|
||
: (locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '');
|
||
});
|
||
// Always restore the default labels. The action handlers leave the text in a
|
||
// transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset
|
||
// skips the refresh while guest-locked — so reset on BOTH transitions: on lock
|
||
// (host enabled host-only just as the guest clicked → don't freeze "Playing...")
|
||
// and on unlock (L-1).
|
||
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play';
|
||
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
|
||
}
|
||
|
||
function showSiteAccessNotice(host, originPattern = null, tabId = null) {
|
||
siteAccessBlocked = true;
|
||
siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website';
|
||
siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0
|
||
? originPattern
|
||
: null;
|
||
siteAccessTabId = normalizeTabId(tabId);
|
||
if (elements.siteAccessMessage) {
|
||
elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', {
|
||
host: siteAccessHost
|
||
});
|
||
}
|
||
if (elements.siteAccessRetry) {
|
||
elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null;
|
||
}
|
||
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block';
|
||
setRemoteControlsLocked(hcmGuestLocked);
|
||
}
|
||
|
||
function hideSiteAccessNotice() {
|
||
siteAccessBlocked = false;
|
||
siteAccessHost = null;
|
||
siteAccessOriginPattern = null;
|
||
siteAccessTabId = null;
|
||
if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false;
|
||
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none';
|
||
setRemoteControlsLocked(hcmGuestLocked);
|
||
}
|
||
|
||
function handleTargetTabResponse(response) {
|
||
if (response?.status === 'host_permission_required') {
|
||
showSiteAccessNotice(response.host, response.originPattern, response.tabId);
|
||
return false;
|
||
}
|
||
if (response?.status === 'ok') {
|
||
hideSiteAccessNotice();
|
||
return true;
|
||
}
|
||
if (response?.status === 'superseded') return false;
|
||
showToast(response?.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
|
||
return false;
|
||
}
|
||
|
||
function selectTargetTab(tabId, tabTitle) {
|
||
return new Promise(resolve => {
|
||
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
|
||
if (chrome.runtime.lastError) {
|
||
showToast(chrome.runtime.lastError.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
|
||
resolve(false);
|
||
return;
|
||
}
|
||
resolve(handleTargetTabResponse(response));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function refreshTargetAccessState() {
|
||
const status = await new Promise(resolve => {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
|
||
if (chrome.runtime.lastError) {
|
||
resolve(null);
|
||
return;
|
||
}
|
||
resolve(response || null);
|
||
});
|
||
});
|
||
if (!status) return;
|
||
if (status.pendingTargetTabId) {
|
||
showSiteAccessNotice(
|
||
status.pendingTargetHost,
|
||
status.pendingTargetOriginPattern,
|
||
status.pendingTargetTabId
|
||
);
|
||
} else {
|
||
hideSiteAccessNotice();
|
||
}
|
||
await populateTabs(
|
||
status.peers,
|
||
status.targetTabId ?? status.pendingTargetTabId ?? null
|
||
);
|
||
}
|
||
|
||
if (elements.hostControlToggle) {
|
||
elements.hostControlToggle.addEventListener('change', () => {
|
||
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
|
||
chrome.runtime.sendMessage({ type: 'SET_CONTROL_MODE', controlMode: mode }, (res) => {
|
||
// Server broadcasts CONTROL_MODE back to refresh the UI; revert on failure.
|
||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||
elements.hostControlToggle.checked = (mode === 'everyone');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
|
||
const inRoom = !!roomId;
|
||
toggleUIState(inRoom);
|
||
if (inRoom) {
|
||
const validChatKey = normalizeChatKey(chatKey);
|
||
let invite = validChatKey
|
||
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
|
||
roomId,
|
||
password,
|
||
chatKey: validChatKey,
|
||
serverUrl: useCustomServer ? serverUrl : ''
|
||
})}`
|
||
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
|
||
elements.inviteLink.value = invite;
|
||
|
||
if (window.justCreatedRoom) {
|
||
window.justCreatedRoom = false;
|
||
if (elements.autoCopyInvite && elements.autoCopyInvite.checked && elements.copyInvite) {
|
||
elements.copyInvite.click();
|
||
}
|
||
}
|
||
|
||
if (elements.activeRoomId) elements.activeRoomId.textContent = roomId;
|
||
if (elements.activeServer) {
|
||
elements.activeServer.textContent = useCustomServer ? (serverUrl || getMessage('LABEL_CUSTOM_SERVER')) : getMessage('ACTIVE_SERVER_OFFICIAL');
|
||
elements.activeServer.title = useCustomServer ? (serverUrl || '') : 'syncserver.koalastuff.net';
|
||
}
|
||
} else {
|
||
updatePeerList([]);
|
||
if (elements.inviteLink) elements.inviteLink.value = '';
|
||
if (elements.activeRoomId) elements.activeRoomId.textContent = '';
|
||
if (elements.activeServer) {
|
||
elements.activeServer.textContent = '';
|
||
elements.activeServer.title = '';
|
||
}
|
||
lastKnownPeers = [];
|
||
}
|
||
}
|
||
|
||
function updateLastActionUI(state, peers) {
|
||
if (!state || !state.action) {
|
||
elements.lastActionCard.replaceChildren();
|
||
const el = document.createElement('div');
|
||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 10px;';
|
||
el.textContent = getMessage('NO_RECENT_COMMANDS');
|
||
elements.lastActionCard.appendChild(el);
|
||
return;
|
||
}
|
||
|
||
const safePeers = peers || [];
|
||
const safeAcks = state.acks || [];
|
||
|
||
const actionNames = {
|
||
'play': getMessage('BTN_PLAY'),
|
||
'pause': getMessage('BTN_PAUSE'),
|
||
'seek': getMessage('NOTIF_SEEK').toUpperCase(),
|
||
'force_sync_prepare': getMessage('BTN_STATE_SYNCING').toUpperCase(),
|
||
'force_sync_execute': getMessage('BTN_STATE_SYNCED').toUpperCase()
|
||
};
|
||
|
||
let senderName = state.senderId === 'You' ? (getMessage('LABEL_YOU') || 'YOU') : state.senderId;
|
||
const senderPeer = safePeers.find(p => (p.peerId || p) === state.senderId);
|
||
if (senderPeer && senderPeer.username) senderName = senderPeer.username;
|
||
|
||
const ts = state.timestamp ? new Date(state.timestamp) : new Date();
|
||
const timeStr = isNaN(ts.getTime()) ? '--:--' : ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
|
||
elements.lastActionCard.innerHTML = '';
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:6px; align-items:baseline;';
|
||
|
||
const actionSpan = document.createElement('span');
|
||
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:11px;';
|
||
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
|
||
|
||
const infoSpan = document.createElement('span');
|
||
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||
infoSpan.textContent = `${senderName} @ ${timeStr}`;
|
||
|
||
header.appendChild(actionSpan);
|
||
header.appendChild(infoSpan);
|
||
elements.lastActionCard.appendChild(header);
|
||
|
||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||
const timeInfo = document.createElement('div');
|
||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||
elements.lastActionCard.appendChild(timeInfo);
|
||
}
|
||
|
||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||
const timeInfo = document.createElement('div');
|
||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||
elements.lastActionCard.appendChild(timeInfo);
|
||
}
|
||
|
||
const grid = document.createElement('div');
|
||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
|
||
|
||
safePeers.forEach(peer => {
|
||
const pId = typeof peer === 'object' ? peer.peerId : peer;
|
||
if (pId === localPeerId) return;
|
||
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
||
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
|
||
const color = isAcked ? 'var(--success)' : 'var(--border-strong)';
|
||
const icon = isAcked ? '✓' : '...';
|
||
const avatar = getAvatarForName(pName);
|
||
|
||
const peerItem = document.createElement('div');
|
||
peerItem.title = pName;
|
||
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
|
||
|
||
const dot = document.createElement('div');
|
||
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:${isAcked ? 'var(--text-on-green)' : 'var(--text-primary)'}; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
|
||
dot.textContent = icon;
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.style.cssText = 'font-size:9px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:42px;';
|
||
nameSpan.textContent = `${avatar} ${pName}`;
|
||
|
||
peerItem.appendChild(dot);
|
||
peerItem.appendChild(nameSpan);
|
||
grid.appendChild(peerItem);
|
||
});
|
||
|
||
elements.lastActionCard.appendChild(grid);
|
||
}
|
||
|
||
function formatTime(seconds) {
|
||
if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return '--:--';
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
return `${m}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function getVolumeIcon(volume, muted) {
|
||
if (muted || volume === 0) return '🔇';
|
||
if (volume < 0.33) return '🔈';
|
||
if (volume < 0.66) return '🔉';
|
||
return '🔊';
|
||
}
|
||
|
||
let activePeers = [];
|
||
let interpolationInterval = null;
|
||
|
||
function stopInterpolation() {
|
||
if (interpolationInterval) {
|
||
clearInterval(interpolationInterval);
|
||
interpolationInterval = null;
|
||
}
|
||
}
|
||
|
||
function startInterpolation() {
|
||
if (interpolationInterval) return;
|
||
interpolationInterval = setInterval(() => {
|
||
const timeElements = document.querySelectorAll('.peer-time-display');
|
||
timeElements.forEach(el => {
|
||
const peerId = el.dataset.peerId;
|
||
const peer = activePeers.find(p => p.peerId === peerId);
|
||
if (peer && peer.playbackState === 'playing' && peer.currentTime != null && peer.lastHeartbeat) {
|
||
const elapsed = (Date.now() - peer.lastHeartbeat) / 1000;
|
||
if (elapsed < 45) {
|
||
el.textContent = formatTime(peer.currentTime + elapsed);
|
||
}
|
||
}
|
||
});
|
||
}, 1000);
|
||
}
|
||
|
||
function renderEmpty(container, type) {
|
||
const states = {
|
||
peers: { icon: '\u{1F465}', title: getMessage('EMPTY_PEERS_TITLE'), hint: getMessage('EMPTY_PEERS_HINT') },
|
||
history: { icon: '\u{1F4CB}', title: getMessage('EMPTY_HISTORY_TITLE'), hint: getMessage('EMPTY_HISTORY_HINT') },
|
||
logs: { icon: '\u{1F4DD}', title: getMessage('EMPTY_LOGS_TITLE'), hint: getMessage('EMPTY_LOGS_HINT') },
|
||
rooms: { icon: '\u{1F50D}', title: getMessage('EMPTY_ROOMS_TITLE'), hint: getMessage('EMPTY_ROOMS_HINT') }
|
||
};
|
||
const state = states[type] || { icon: '', title: '', hint: '' };
|
||
const wrapper = document.createElement('div');
|
||
wrapper.style.cssText = 'text-align:center; padding:16px 8px; color:var(--text-muted);';
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.style.cssText = 'font-size:24px; margin-bottom:6px;';
|
||
iconDiv.textContent = state.icon;
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.style.cssText = 'font-size:12px; font-weight:600; margin-bottom:4px;';
|
||
titleDiv.textContent = state.title;
|
||
const hintDiv = document.createElement('div');
|
||
hintDiv.style.cssText = 'font-size:10px; opacity:0.7;';
|
||
hintDiv.textContent = state.hint;
|
||
wrapper.appendChild(iconDiv);
|
||
wrapper.appendChild(titleDiv);
|
||
wrapper.appendChild(hintDiv);
|
||
|
||
// "No peers yet" — turn the hint into a one-click action so the user can
|
||
// immediately copy and share the invite link instead of hunting for it.
|
||
if (type === 'peers' && elements.inviteLink && elements.inviteLink.value) {
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.type = 'button';
|
||
copyBtn.className = 'primary';
|
||
copyBtn.style.cssText = 'display:inline-block; width:auto; margin-top:12px; padding:6px 14px; font-size:11px;';
|
||
copyBtn.textContent = getMessage('BTN_COPY_INVITE');
|
||
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
|
||
copyBtn.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(elements.inviteLink.value)
|
||
.then(() => {
|
||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||
spawnLeafBurst(copyBtn);
|
||
})
|
||
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
|
||
});
|
||
wrapper.appendChild(copyBtn);
|
||
}
|
||
|
||
container.replaceChildren(wrapper);
|
||
}
|
||
|
||
function updatePeerList(peers) {
|
||
if (!peers) return;
|
||
activePeers = peers;
|
||
if (peers.length === 0) {
|
||
stopInterpolation();
|
||
} else if (!interpolationInterval) {
|
||
startInterpolation();
|
||
}
|
||
|
||
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
|
||
const stateToHash = peers.map(p => ({
|
||
id: p.peerId,
|
||
user: p.username,
|
||
tab: p.tabTitle,
|
||
media: p.mediaTitle,
|
||
state: p.playbackState,
|
||
vol: p.volume,
|
||
muted: p.muted
|
||
}));
|
||
const currentPeersJson = JSON.stringify(stateToHash);
|
||
if (currentPeersJson === lastPeersJson) return;
|
||
lastPeersJson = currentPeersJson;
|
||
|
||
const renderPeers = (container) => {
|
||
container.innerHTML = '';
|
||
if (peers.length === 0) {
|
||
renderEmpty(container, 'peers');
|
||
return;
|
||
}
|
||
|
||
peers.forEach(p => {
|
||
const pId = typeof p === 'object' ? p.peerId : p;
|
||
const pUsername = (typeof p === 'object' && p.username) ? p.username : '';
|
||
const pTabTitle = (typeof p === 'object' && p.tabTitle) ? p.tabTitle : '';
|
||
|
||
const peerItem = document.createElement('div');
|
||
peerItem.className = 'peer-item';
|
||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid var(--border-soft);';
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.style.cssText = 'display: inline-flex; align-items: center; max-width: 200px; overflow: hidden; white-space: nowrap;';
|
||
const avatar = getAvatarForName(pUsername || pId);
|
||
if (pUsername) {
|
||
const u = document.createElement('span');
|
||
u.style.cssText = 'font-weight:600; color:var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||
u.textContent = `${avatar} ${pUsername}`;
|
||
const i = document.createElement('span');
|
||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;';
|
||
i.textContent = ` (${pId})`;
|
||
nameSpan.appendChild(u);
|
||
nameSpan.appendChild(i);
|
||
} else {
|
||
nameSpan.style.fontWeight = '600';
|
||
nameSpan.style.cssText = 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;';
|
||
nameSpan.textContent = `${avatar} ${pId}`;
|
||
}
|
||
|
||
header.appendChild(nameSpan);
|
||
|
||
// Right-side badges + actions, kept in one group so they sit together
|
||
// on the right instead of being scattered by the header's space-between.
|
||
const rightGroup = document.createElement('div');
|
||
rightGroup.style.cssText = 'display:flex; align-items:center; gap:6px; flex-shrink:0;';
|
||
|
||
// Volume Icon (Top Right)
|
||
if (p.volume !== undefined && p.volume !== null) {
|
||
const volIcon = document.createElement('div');
|
||
volIcon.style.cssText = 'position:absolute; top:8px; right:0; cursor:help; font-size:14px;';
|
||
volIcon.textContent = getVolumeIcon(p.volume, p.muted);
|
||
volIcon.title = p.muted ? 'Muted' : `Volume: ${Math.round(p.volume * 100)}%`;
|
||
peerItem.appendChild(volIcon);
|
||
}
|
||
|
||
if (pId === localPeerId) {
|
||
const you = document.createElement('span');
|
||
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
|
||
you.textContent = getMessage('LABEL_YOU') || 'YOU';
|
||
rightGroup.appendChild(you);
|
||
}
|
||
|
||
// Host Control Mode: show "Solo" badge for peers watching on their own.
|
||
if (typeof p === 'object' && p.desynced) {
|
||
const solo = document.createElement('span');
|
||
solo.style.cssText = 'font-size:10px; color:var(--text-on-warm); background:var(--accent-terracotta); padding:2px 6px; border-radius:6px; font-weight:700;';
|
||
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
|
||
solo.textContent = soloText;
|
||
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
|
||
rightGroup.appendChild(solo);
|
||
}
|
||
|
||
// Co-Host: role badges + the owner's promote/demote control. Only meaningful
|
||
// once the room is locked to host-only — in 'everyone' mode anyone can already
|
||
// control, so roles are moot and we show nothing (no badge/button noise).
|
||
// The promote/demote button additionally requires owner privileges (below).
|
||
const showRoles = !!hcmOwnerPeerId && hcmRoomIsHostOnly;
|
||
if (showRoles) {
|
||
const isOwner = pId === hcmOwnerPeerId;
|
||
const isController = !isOwner && hcmControllers.includes(pId);
|
||
if (isOwner || isController) {
|
||
const roleBadge = document.createElement('span');
|
||
roleBadge.style.cssText = 'font-size:10px; color:var(--text-on-green); background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:700;';
|
||
roleBadge.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
|
||
rightGroup.appendChild(roleBadge);
|
||
}
|
||
// Owner can promote/demote any non-owner peer.
|
||
if (hcmAmOwner && hcmCoHostSupported && !isOwner) {
|
||
const btn = document.createElement('button');
|
||
const revoke = isController;
|
||
btn.style.cssText = `font-size:10px; padding:3px 9px; border-radius:6px; cursor:pointer; white-space:nowrap; font-weight:600; transition:background .15s,color .15s; border:1px solid ${revoke ? 'var(--text-muted)' : 'var(--accent)'}; background:transparent; color:${revoke ? 'var(--text-muted)' : 'var(--accent)'};`;
|
||
btn.textContent = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||
btn.title = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||
btn.addEventListener('mouseenter', () => {
|
||
btn.style.background = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||
btn.style.color = 'var(--text-on-green)';
|
||
});
|
||
btn.addEventListener('mouseleave', () => {
|
||
btn.style.background = 'transparent';
|
||
btn.style.color = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||
});
|
||
btn.addEventListener('click', () => {
|
||
chrome.runtime.sendMessage({ type: 'SET_PEER_ROLE', peerId: pId, controller: !isController }, () => {});
|
||
});
|
||
rightGroup.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
if (rightGroup.childNodes.length) header.appendChild(rightGroup);
|
||
|
||
peerItem.appendChild(header);
|
||
|
||
// Media Info
|
||
if (p.mediaTitle) {
|
||
const mediaDiv = document.createElement('div');
|
||
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;';
|
||
mediaDiv.textContent = `🎬 ${p.mediaTitle}`;
|
||
peerItem.appendChild(mediaDiv);
|
||
}
|
||
|
||
// Status Line (Play/Pause + Time)
|
||
const statusLine = document.createElement('div');
|
||
statusLine.style.cssText = 'display:flex; align-items:center; gap:8px; margin-top:4px;';
|
||
|
||
if (p.playbackState) {
|
||
const stateIcon = document.createElement('span');
|
||
stateIcon.style.fontSize = '10px';
|
||
if (p.playbackState === 'playing') {
|
||
stateIcon.textContent = '▶';
|
||
stateIcon.style.color = 'var(--success)';
|
||
} else {
|
||
stateIcon.textContent = '⏸';
|
||
stateIcon.style.color = 'var(--error)';
|
||
}
|
||
statusLine.appendChild(stateIcon);
|
||
}
|
||
|
||
if (p.currentTime !== undefined && p.currentTime !== null) {
|
||
const timeSpan = document.createElement('span');
|
||
timeSpan.className = 'peer-time-display';
|
||
timeSpan.dataset.peerId = pId;
|
||
timeSpan.style.cssText = 'font-size:11px; font-family:monospace; color:var(--text-muted);';
|
||
|
||
let displayTime = p.currentTime;
|
||
if (p.playbackState === 'playing' && p.lastHeartbeat && p.currentTime != null) {
|
||
const elapsed = (Date.now() - p.lastHeartbeat) / 1000;
|
||
if (elapsed < 45) {
|
||
displayTime += elapsed;
|
||
}
|
||
}
|
||
timeSpan.textContent = formatTime(displayTime);
|
||
statusLine.appendChild(timeSpan);
|
||
}
|
||
|
||
if (pTabTitle) {
|
||
const titleDiv = document.createElement('span');
|
||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right;';
|
||
titleDiv.textContent = pTabTitle;
|
||
statusLine.appendChild(titleDiv);
|
||
}
|
||
|
||
peerItem.appendChild(statusLine);
|
||
container.appendChild(peerItem);
|
||
});
|
||
};
|
||
|
||
if (elements.peerList) renderPeers(elements.peerList);
|
||
if (elements.peerListSync) renderPeers(elements.peerListSync);
|
||
|
||
// Re-populate tabs to update Star Matching when peers change
|
||
populateTabs(peers);
|
||
}
|
||
|
||
function detectPeerChanges(newPeers) {
|
||
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
|
||
const newIds = new Set(newPeers.map(p => p.peerId || p));
|
||
|
||
for (const peer of newPeers) {
|
||
const id = peer.peerId || peer;
|
||
if (!oldIds.has(id)) {
|
||
const name = peer.username || id.substring(0, 4);
|
||
showToast(getMessage('TOAST_PEER_JOINED', { name }), 'success');
|
||
}
|
||
}
|
||
|
||
for (const oldPeer of lastKnownPeers) {
|
||
const id = oldPeer.peerId || oldPeer;
|
||
if (!newIds.has(id)) {
|
||
const name = oldPeer.username || id.substring(0, 4);
|
||
showToast(getMessage('TOAST_PEER_LEFT', { name }), 'info');
|
||
}
|
||
}
|
||
|
||
lastKnownPeers = newPeers;
|
||
}
|
||
|
||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||
const token = {};
|
||
populateTabsToken = token;
|
||
|
||
const data = await chrome.storage.local.get(['filterNoise']);
|
||
const isFilterActive = data.filterNoise !== false;
|
||
|
||
let currentTargetTabId = providedTargetTabId;
|
||
if (currentTargetTabId === null) {
|
||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||
if (chrome.runtime.lastError) {
|
||
if (populateTabsToken !== token) return;
|
||
currentTargetTabId = null;
|
||
} else {
|
||
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
|
||
}
|
||
}
|
||
|
||
let peerIds = providedPeers;
|
||
if (!peerIds) {
|
||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||
if (chrome.runtime.lastError) {
|
||
if (populateTabsToken !== token) return;
|
||
peerIds = [];
|
||
} else {
|
||
peerIds = status?.peers || [];
|
||
}
|
||
}
|
||
|
||
let tabs = [];
|
||
try {
|
||
tabs = await chrome.tabs.query({});
|
||
} catch (e) {
|
||
console.warn('[Popup] tabs.query failed:', e.message);
|
||
if (populateTabsToken !== token) return;
|
||
}
|
||
|
||
if (!elements.targetTab) return;
|
||
if (populateTabsToken !== token) return;
|
||
while (elements.targetTab.options.length > 1) {
|
||
elements.targetTab.remove(1);
|
||
}
|
||
|
||
const filteredTabs = tabs.filter(tab => {
|
||
if (!tab.url || tab.url.startsWith('chrome://')) return false;
|
||
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
|
||
const urlStr = tab.url.toLowerCase();
|
||
if (BLACKLIST_DOMAINS.some(d => {
|
||
const domain = d.toLowerCase();
|
||
try {
|
||
const hostname = new URL(tab.url).hostname.toLowerCase();
|
||
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
|
||
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
|
||
} catch {
|
||
/* ignore invalid URLs */
|
||
}
|
||
return urlStr.includes(domain);
|
||
})) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
|
||
const cleanTitle = (rawTitle) => {
|
||
if (!rawTitle) return '';
|
||
return (normalizeTabTitle(rawTitle) || '')
|
||
.replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '')
|
||
.replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '')
|
||
.trim();
|
||
};
|
||
|
||
const peerTitles = peerIds
|
||
.filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId)
|
||
.map(p => (typeof p === 'object' ? p.tabTitle : null))
|
||
.filter(t => t && t.length > 3)
|
||
.map(t => cleanTitle(t).toLowerCase())
|
||
.filter(t => t.length > 3);
|
||
|
||
filteredTabs.forEach(tab => {
|
||
const option = document.createElement('option');
|
||
option.value = tab.id;
|
||
const rawTitle = (tab.title || 'Loading...');
|
||
const title = cleanTitle(rawTitle).toLowerCase();
|
||
|
||
const isMatch = title.length > 3 && peerTitles.some(pt => {
|
||
return title.includes(pt) || pt.includes(title);
|
||
});
|
||
|
||
let label = rawTitle.substring(0, 45) + (rawTitle.length > 45 ? '...' : '');
|
||
if (isMatch) {
|
||
label = `⭐ MATCH: ${label}`;
|
||
option.style.fontWeight = 'bold';
|
||
option.style.color = 'var(--star)';
|
||
}
|
||
|
||
if (tab.audible) {
|
||
label = `[🎬] ${label}`;
|
||
}
|
||
|
||
option.textContent = label;
|
||
option.dataset.originalTitle = tab.title;
|
||
elements.targetTab.appendChild(option);
|
||
});
|
||
|
||
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
||
const options = Array.from(elements.targetTab.options);
|
||
const placeholder = options.shift();
|
||
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
|
||
|
||
options.sort((a, b) => {
|
||
const aId = parseInt(a.value);
|
||
const bId = parseInt(b.value);
|
||
|
||
if (aId === currentTabId) return -1;
|
||
if (bId === currentTabId) return 1;
|
||
|
||
const aMatch = a.textContent.includes('⭐');
|
||
const bMatch = b.textContent.includes('⭐');
|
||
if (aMatch && !bMatch) return -1;
|
||
if (!aMatch && bMatch) return 1;
|
||
|
||
return a.textContent.localeCompare(b.textContent);
|
||
});
|
||
elements.targetTab.innerHTML = '';
|
||
elements.targetTab.appendChild(placeholder);
|
||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||
|
||
if (currentTargetTabId) {
|
||
elements.targetTab.value = currentTargetTabId;
|
||
} else {
|
||
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
|
||
if (matchOpt && elements.targetTab.options.length > 1) {
|
||
elements.targetTab.value = matchOpt.value;
|
||
const tabTitle = matchOpt.dataset.originalTitle || null;
|
||
selectTargetTab(parseInt(matchOpt.value), tabTitle);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyConnectionStatus(status) {
|
||
// Coerce 'idle' to 'disconnected' if a room is actively configured.
|
||
// An 'idle' status with a configured room indicates the background
|
||
// worker dropped connection intent due to a server error.
|
||
if (status === 'idle' && elements.sectionActive && elements.sectionActive.style.display !== 'none') {
|
||
status = 'disconnected';
|
||
}
|
||
|
||
const connected = status === 'connected';
|
||
const connecting = status === 'connecting';
|
||
const reconnecting = status === 'reconnecting';
|
||
// 'idle' = not in a room and not trying to connect. This is the normal
|
||
// resting state (lazy connect), NOT an error — surface it neutrally.
|
||
const idle = status === 'idle';
|
||
|
||
if (elements.connDot) {
|
||
const dotStatus = connected ? 'status-online' : ((connecting || reconnecting) ? 'status-connecting' : (idle ? 'status-idle' : 'status-offline'));
|
||
elements.connDot.className = `status-dot ${dotStatus}`;
|
||
}
|
||
|
||
if (elements.connText) {
|
||
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : (idle ? getMessage('STATUS_IDLE') : getMessage('STATUS_DISCONNECTED'))));
|
||
}
|
||
|
||
// Show the info "i" + tooltip only in the idle state.
|
||
if (elements.connInfo) {
|
||
elements.connInfo.style.display = idle ? '' : 'none';
|
||
}
|
||
if (!connected) {
|
||
updatePingDisplay(null);
|
||
}
|
||
if (elements.retryBtn) {
|
||
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
|
||
}
|
||
|
||
if (elements.joinBtn) {
|
||
if (connecting || reconnecting) {
|
||
elements.joinBtn.disabled = !reconnectSlowMode;
|
||
elements.joinBtn.textContent = connecting ? getMessage('BTN_STATE_JOINING') : getMessage('BTN_STATE_RECONNECTING');
|
||
} else {
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
}
|
||
}
|
||
|
||
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY');
|
||
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
|
||
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
|
||
}
|
||
|
||
function updatePingDisplay(pingMs) {
|
||
if (!elements.connPing) return;
|
||
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
|
||
elements.connPing.textContent = '';
|
||
elements.connPing.className = 'conn-ping';
|
||
return;
|
||
}
|
||
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
|
||
if (pingMs < 75) {
|
||
elements.connPing.className = 'conn-ping conn-ping-good';
|
||
} else if (pingMs < 150) {
|
||
elements.connPing.className = 'conn-ping conn-ping-warning';
|
||
} else {
|
||
elements.connPing.className = 'conn-ping conn-ping-bad';
|
||
}
|
||
}
|
||
|
||
function updateHistory(history) {
|
||
if (!history || !elements.historyList) return;
|
||
elements.historyList.innerHTML = '';
|
||
|
||
if (history.length === 0) {
|
||
renderEmpty(elements.historyList, 'history');
|
||
return;
|
||
}
|
||
|
||
history.forEach(item => {
|
||
const time = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', '');
|
||
|
||
const entry = document.createElement('div');
|
||
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px;';
|
||
|
||
const timeSpan = document.createElement('span');
|
||
timeSpan.style.color = 'var(--text-muted)';
|
||
timeSpan.textContent = `[${time}] `;
|
||
|
||
const actionBold = document.createElement('b');
|
||
actionBold.textContent = actionLabel;
|
||
|
||
const textNode1 = document.createTextNode(' by ');
|
||
|
||
const senderSpan = document.createElement('span');
|
||
if (item.senderId === 'You') {
|
||
senderSpan.style.color = 'var(--accent)';
|
||
senderSpan.textContent = getMessage('LABEL_YOU') || 'You';
|
||
} else {
|
||
senderSpan.textContent = item.senderId;
|
||
}
|
||
|
||
entry.appendChild(timeSpan);
|
||
entry.appendChild(actionBold);
|
||
entry.appendChild(textNode1);
|
||
entry.appendChild(senderSpan);
|
||
|
||
elements.historyList.appendChild(entry);
|
||
});
|
||
}
|
||
|
||
function refreshHistory() {
|
||
chrome.runtime.sendMessage({ type: 'GET_HISTORY' }, (res) => {
|
||
if (res) updateHistory(res);
|
||
});
|
||
}
|
||
|
||
function updateRoomList(rooms) {
|
||
if (!elements.publicRooms) return;
|
||
elements.publicRooms.innerHTML = '';
|
||
|
||
if (!rooms || rooms.length === 0) {
|
||
renderEmpty(elements.publicRooms, 'rooms');
|
||
return;
|
||
}
|
||
|
||
rooms.forEach(r => {
|
||
const item = document.createElement('div');
|
||
item.className = 'room-item';
|
||
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid var(--border-soft); cursor:pointer;';
|
||
item.dataset.id = r.id;
|
||
|
||
const leftSide = document.createElement('div');
|
||
leftSide.style.cssText = 'display:flex; align-items:center; gap: 6px;';
|
||
|
||
const idSpan = document.createElement('span');
|
||
idSpan.style.fontWeight = '600';
|
||
idSpan.textContent = r.id;
|
||
|
||
leftSide.appendChild(idSpan);
|
||
|
||
if (r.hasPassword) {
|
||
const lock = document.createElement('span');
|
||
lock.title = getMessage('LABEL_PASSWORD_PROTECTED');
|
||
lock.textContent = '🔒';
|
||
leftSide.appendChild(lock);
|
||
}
|
||
|
||
const peerCount = document.createElement('span');
|
||
peerCount.style.cssText = 'font-size:11px; color:var(--accent)';
|
||
peerCount.textContent = getMessage('LABEL_PEERS_COUNT', { count: parseInt(r.peerCount) });
|
||
|
||
item.appendChild(leftSide);
|
||
item.appendChild(peerCount);
|
||
|
||
item.addEventListener('click', () => {
|
||
elements.roomId.value = r.id;
|
||
elements.password.value = '';
|
||
elements.password.focus();
|
||
});
|
||
|
||
elements.publicRooms.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function checkInviteLink() {
|
||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||
const tab = tabs[0];
|
||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
|
||
try {
|
||
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
|
||
if (!invite) return;
|
||
elements.roomId.value = normalizeRoomId(invite.roomId);
|
||
elements.password.value = invite.password;
|
||
pendingInviteRoomId = elements.roomId.value;
|
||
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
|
||
|
||
elements.serverUrl.value = invite.serverUrl;
|
||
setServerMode(invite.useCustomServer);
|
||
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
|
||
|
||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||
} catch (_e) {
|
||
// Malformed invite link, ignore
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function setServerMode(custom) {
|
||
elements.serverOfficial.classList.toggle('active', !custom);
|
||
elements.serverCustom.classList.toggle('active', custom);
|
||
elements.serverUrl.style.display = custom ? 'block' : 'none';
|
||
chrome.storage.local.get(['useCustomServer', 'serverUrl'], (data) => {
|
||
if (data.useCustomServer !== custom) {
|
||
chrome.storage.local.set({ useCustomServer: custom });
|
||
if (!custom || data.serverUrl) {
|
||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
elements.serverOfficial.addEventListener('click', () => setServerMode(false));
|
||
elements.serverCustom.addEventListener('click', () => setServerMode(true));
|
||
|
||
elements.filterNoise.addEventListener('change', () => {
|
||
chrome.storage.local.set({ filterNoise: elements.filterNoise.checked }, () => {
|
||
populateTabs();
|
||
});
|
||
});
|
||
|
||
elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||
});
|
||
|
||
function syncChatSettingsState() {
|
||
const enabled = elements.chatEnabled?.checked === true;
|
||
for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
|
||
if (control) control.disabled = !enabled;
|
||
}
|
||
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
||
row.classList.toggle('is-disabled', !enabled);
|
||
row.setAttribute('aria-disabled', String(!enabled));
|
||
});
|
||
}
|
||
|
||
if (elements.chatEnabled) {
|
||
elements.chatEnabled.addEventListener('change', () => {
|
||
syncChatSettingsState();
|
||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
||
});
|
||
}
|
||
if (elements.chatNotifications) {
|
||
elements.chatNotifications.addEventListener('change', () => {
|
||
chrome.storage.local.set({ chatNotifications: elements.chatNotifications.checked });
|
||
});
|
||
}
|
||
if (elements.chatPosition) {
|
||
elements.chatPosition.addEventListener('change', () => {
|
||
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
|
||
chrome.storage.local.set({ chatPosition });
|
||
});
|
||
}
|
||
if (elements.chatSize) {
|
||
elements.chatSize.addEventListener('change', () => {
|
||
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
|
||
chrome.storage.local.set({ chatSize });
|
||
});
|
||
}
|
||
if (elements.chatStartMode) {
|
||
elements.chatStartMode.addEventListener('change', () => {
|
||
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
|
||
});
|
||
}
|
||
|
||
if (elements.sendTabTitle) {
|
||
elements.sendTabTitle.addEventListener('change', () => {
|
||
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
|
||
chrome.runtime.sendMessage({ type: 'TITLE_PRIVACY_CHANGED' }).catch(() => {});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (elements.mediaTitlePrivacyMode) {
|
||
elements.mediaTitlePrivacyMode.addEventListener('change', () => {
|
||
chrome.storage.local.set({ mediaTitlePrivacyMode: elements.mediaTitlePrivacyMode.value }, () => {
|
||
chrome.runtime.sendMessage({ type: 'TITLE_PRIVACY_CHANGED' }).catch(() => {});
|
||
});
|
||
});
|
||
}
|
||
|
||
elements.browserNotifications.addEventListener('change', () => {
|
||
chrome.storage.local.set({ browserNotifications: elements.browserNotifications.checked });
|
||
});
|
||
|
||
if (elements.autoCopyInvite) {
|
||
elements.autoCopyInvite.addEventListener('change', () => {
|
||
chrome.storage.local.set({ autoCopyInvite: elements.autoCopyInvite.checked });
|
||
});
|
||
}
|
||
|
||
if (elements.themeSelector) {
|
||
elements.themeSelector.addEventListener('change', () => {
|
||
const themeMode = elements.themeSelector.value;
|
||
flashThemeTransition();
|
||
window.koalaTheme?.setMode(themeMode);
|
||
chrome.storage.local.set({ themeMode });
|
||
syncThemePicker();
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
if (!elements.themeSelector) return;
|
||
elements.themeSelector.value = button.dataset.themeValue;
|
||
elements.themeSelector.dispatchEvent(new window.Event('change'));
|
||
});
|
||
});
|
||
|
||
if (elements.paletteSelector) {
|
||
elements.paletteSelector.addEventListener('change', () => {
|
||
const themePalette = elements.paletteSelector.value;
|
||
flashThemeTransition();
|
||
window.koalaTheme?.setPalette(themePalette);
|
||
chrome.storage.local.set({ themePalette });
|
||
syncPalettePicker();
|
||
dismissFeatureHint('theme_picker');
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
if (!elements.paletteSelector) return;
|
||
elements.paletteSelector.value = button.dataset.paletteValue;
|
||
elements.paletteSelector.dispatchEvent(new window.Event('change'));
|
||
});
|
||
});
|
||
|
||
if (elements.audioSettingsLink) {
|
||
elements.audioSettingsLink.addEventListener('click', async (event) => {
|
||
event.preventDefault();
|
||
await dismissFeatureHint('audio_processing');
|
||
openAudioSettingsPage();
|
||
});
|
||
}
|
||
|
||
chrome.storage.onChanged.addListener((changes, area) => {
|
||
if (area !== 'sync' || !changes.audioSettings) return;
|
||
});
|
||
|
||
elements.forceSyncMode.addEventListener('change', () => {
|
||
chrome.storage.local.set({ forceSyncMode: elements.forceSyncMode.value });
|
||
});
|
||
|
||
elements.serverUrl.addEventListener('input', () => {
|
||
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
|
||
});
|
||
|
||
elements.username.addEventListener('input', syncDevToolsVisibility);
|
||
elements.username.addEventListener('change', () => {
|
||
chrome.storage.local.set({ username: elements.username.value });
|
||
syncDevToolsVisibility();
|
||
});
|
||
|
||
if (elements.langSelector) {
|
||
elements.langSelector.addEventListener('change', async () => {
|
||
const selectedLang = elements.langSelector.value;
|
||
await chrome.storage.local.set({ locale: selectedLang });
|
||
await loadLocale(selectedLang);
|
||
translateDOM();
|
||
localizeThemeSelector(selectedLang);
|
||
configureFooterLinks();
|
||
await updateFeatureHints();
|
||
|
||
// Re-apply connection and room UI state since translateDOM may overwrite dynamic elements
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||
if (chrome.runtime.lastError) return;
|
||
if (res) {
|
||
localPeerId = res.peerId;
|
||
reconnectSlowMode = res.reconnectSlowMode || false;
|
||
applyConnectionStatus(res.status);
|
||
updatePeerList(res.peers);
|
||
lastKnownPeers = res.peers || [];
|
||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||
|
||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||
|
||
if (res.pendingTargetTabId) {
|
||
showSiteAccessNotice(
|
||
res.pendingTargetHost,
|
||
res.pendingTargetOriginPattern,
|
||
res.pendingTargetTabId
|
||
);
|
||
} else {
|
||
hideSiteAccessNotice();
|
||
}
|
||
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
|
||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||
} else {
|
||
applyConnectionStatus('disconnected');
|
||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||
await populateTabs();
|
||
}
|
||
});
|
||
|
||
refreshLogs();
|
||
refreshHistory();
|
||
});
|
||
}
|
||
|
||
// Accordion behavior for settings details categories (collapses others when one opens)
|
||
document.querySelectorAll('#tab-settings details.form-group').forEach(det => {
|
||
det.addEventListener('toggle', () => {
|
||
if (det.open) {
|
||
document.querySelectorAll('#tab-settings details.form-group').forEach(other => {
|
||
if (other !== det && other.open) {
|
||
other.removeAttribute('open');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
elements.serverUrl.addEventListener('change', () => {
|
||
let url = elements.serverUrl.value.trim();
|
||
if (url && !url.includes('://')) {
|
||
// Default to secure wss:// — plain ws:// is only valid for a local relay
|
||
// (and the background rejects/upgrades ws:// for any remote host anyway).
|
||
const host = url.split('/')[0].split(':')[0].toLowerCase();
|
||
const isLocal = host === 'localhost' || host === '127.0.0.1';
|
||
url = (isLocal ? 'ws://' : 'wss://') + url;
|
||
elements.serverUrl.value = url;
|
||
chrome.storage.local.set({ serverUrl: url });
|
||
}
|
||
if (elements.serverCustom.classList.contains('active') && url) {
|
||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||
}
|
||
});
|
||
|
||
elements.tabs.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
elements.tabs.forEach(b => b.classList.remove('active'));
|
||
elements.contents.forEach(c => c.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
|
||
const targetContent = document.getElementById(btn.dataset.tab);
|
||
targetContent.classList.add('active');
|
||
|
||
targetContent.classList.remove('tab-active-animate');
|
||
void targetContent.offsetWidth; // Force reflow to restart animation
|
||
targetContent.classList.add('tab-active-animate');
|
||
|
||
isDevTabVisible = btn.dataset.tab === 'tab-dev';
|
||
if (isDevTabVisible) refreshLogs();
|
||
if (btn.dataset.tab === 'tab-sync') refreshHistory();
|
||
|
||
chrome.storage.local.set({ activeTab: btn.dataset.tab });
|
||
});
|
||
});
|
||
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const container = document.getElementById('toast-container');
|
||
if (!container) return;
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message || '';
|
||
toast.style.whiteSpace = 'pre-wrap';
|
||
|
||
const delay = Math.max(0, duration - 600);
|
||
toast.style.animation = `toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in ${delay}ms forwards`;
|
||
|
||
container.appendChild(toast);
|
||
setTimeout(() => toast.remove(), duration);
|
||
}
|
||
|
||
// Eucalyptus confetti: a few tiny leaves burst from the given element
|
||
// (matches the website's copy-invite moment). Subtle by design — few,
|
||
// tiny, short-lived — and skipped entirely under reduced motion.
|
||
function spawnLeafBurst(sourceEl) {
|
||
if (!sourceEl || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
const rect = sourceEl.getBoundingClientRect();
|
||
if (!rect.width) return;
|
||
const originY = rect.top + rect.height / 2;
|
||
const count = 4 + Math.floor(Math.random() * 3);
|
||
for (let i = 0; i < count; i++) {
|
||
const leaf = document.createElement('span');
|
||
// Three tones of the palette: deep green, sage, a dash of amber
|
||
const tone = Math.random();
|
||
leaf.className = 'click-leaf' + (tone < 0.25 ? ' click-leaf-amber' : tone < 0.55 ? ' click-leaf-sage' : '');
|
||
// Fan out upward to a burst peak spread across the button's width;
|
||
// the keyframes brake there and let each leaf sink while it rocks.
|
||
const angle = (-90 + (Math.random() - 0.5) * 150) * Math.PI / 180;
|
||
const dist = 18 + Math.random() * 24;
|
||
const dur = 0.85 + Math.random() * 0.4;
|
||
leaf.style.left = (rect.left + 4 + Math.random() * Math.max(1, rect.width - 8)) + 'px';
|
||
leaf.style.top = (originY - rect.height * 0.2) + 'px';
|
||
leaf.style.setProperty('--leaf-x', (Math.cos(angle) * dist).toFixed(1) + 'px');
|
||
leaf.style.setProperty('--leaf-y', (Math.sin(angle) * dist).toFixed(1) + 'px');
|
||
leaf.style.setProperty('--leaf-rot', ((Math.random() - 0.5) * 160).toFixed(0) + 'deg');
|
||
leaf.style.setProperty('--leaf-scale', (0.55 + Math.random() * 0.55).toFixed(2));
|
||
leaf.style.setProperty('--leaf-dur', dur.toFixed(2) + 's');
|
||
leaf.style.setProperty('--flutter-dur', (0.35 + Math.random() * 0.3).toFixed(2) + 's');
|
||
leaf.appendChild(document.createElement('i'));
|
||
document.body.appendChild(leaf);
|
||
setTimeout(() => leaf.remove(), dur * 1000 + 150);
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
if (!elements.roomError) return;
|
||
const currentToken = ++errorToken;
|
||
elements.roomError.textContent = msg;
|
||
elements.roomError.style.display = 'block';
|
||
elements.roomId.style.borderColor = 'var(--error)';
|
||
elements.password.style.borderColor = 'var(--error)';
|
||
|
||
showToast(msg, 'error', 5000);
|
||
|
||
setTimeout(() => {
|
||
if (currentToken !== errorToken) return;
|
||
if (elements.roomError) elements.roomError.style.display = 'none';
|
||
elements.roomId.style.borderColor = '';
|
||
elements.password.style.borderColor = '';
|
||
}, 5000);
|
||
}
|
||
|
||
// --- Action Handlers ---
|
||
elements.roomId.addEventListener('input', () => {
|
||
elements.roomId.value = normalizeRoomId(elements.roomId.value);
|
||
pendingRoomCreation = false;
|
||
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
|
||
pendingInviteRoomId = '';
|
||
pendingInviteChatKey = '';
|
||
}
|
||
});
|
||
|
||
elements.joinBtn.addEventListener('click', async () => {
|
||
if (isProcessingConnection) return;
|
||
isProcessingConnection = true;
|
||
clearConnectionErrorTimer();
|
||
if (elements.joinBtn.disabled) {
|
||
isProcessingConnection = false;
|
||
return;
|
||
}
|
||
const roomIdInput = normalizeRoomId(elements.roomId.value);
|
||
const isCreating = pendingRoomCreation || !roomIdInput;
|
||
|
||
elements.joinBtn.disabled = true;
|
||
elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING');
|
||
|
||
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
|
||
joinBtnTimeout = setTimeout(() => {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (res && res.status === 'connecting') {
|
||
joinBtnTimeout = setTimeout(() => {
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
joinBtnTimeout = null;
|
||
isProcessingConnection = false;
|
||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||
}, 15000);
|
||
return;
|
||
}
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
joinBtnTimeout = null;
|
||
isProcessingConnection = false;
|
||
if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT'));
|
||
});
|
||
}, 15000);
|
||
|
||
const serverUrl = elements.serverUrl.value.trim();
|
||
const useCustom = elements.serverCustom.classList.contains('active');
|
||
|
||
// Proactive URL Validation
|
||
if (useCustom && !serverUrl) {
|
||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||
isProcessingConnection = false;
|
||
return;
|
||
}
|
||
if (useCustom && serverUrl) {
|
||
try {
|
||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'wss://' + serverUrl;
|
||
new URL(urlToCheck);
|
||
} catch (_e) {
|
||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||
isProcessingConnection = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
const roomId = roomIdInput || generateRoomId();
|
||
let password = elements.password.value;
|
||
if (isCreating && !password) {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
const array = new Uint8Array(6);
|
||
self.crypto.getRandomValues(array);
|
||
password = Array.from(array, byte => chars[byte % chars.length]).join('');
|
||
elements.password.value = password;
|
||
}
|
||
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
|
||
if (isCreating) {
|
||
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
|
||
chatKey = normalizeChatKey(created?.chatKey);
|
||
if (!chatKey) {
|
||
pendingRoomCreation = false;
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||
isProcessingConnection = false;
|
||
showError(getMessage('CHAT_SEND_FAILED'));
|
||
return;
|
||
}
|
||
}
|
||
if (isCreating) window.justCreatedRoom = true;
|
||
pendingInviteRoomId = '';
|
||
pendingInviteChatKey = '';
|
||
pendingRoomCreation = false;
|
||
|
||
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
|
||
elements.roomId.value = roomId;
|
||
|
||
// Tell background to connect
|
||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||
|
||
// UI Feedback: Immediately switch state for better responsiveness
|
||
updateUI(roomId, password, useCustom, serverUrl, chatKey);
|
||
});
|
||
|
||
elements.leaveBtn.addEventListener('click', async () => {
|
||
if (isProcessingConnection) return;
|
||
isProcessingConnection = true;
|
||
clearConnectionErrorTimer();
|
||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||
elements.roomId.value = '';
|
||
elements.password.value = '';
|
||
lastKnownPeers = [];
|
||
updateUI(null, null);
|
||
isProcessingConnection = false;
|
||
});
|
||
|
||
function generateRoomId() {
|
||
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
|
||
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
|
||
const num = Math.floor(Math.random() * 99) + 1;
|
||
return `${adj.toUpperCase()}-${noun.toUpperCase()}-${num}`;
|
||
}
|
||
|
||
function handleCreateRoom() {
|
||
const secureGenerateId = (length = 6) => {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
const array = new Uint8Array(length);
|
||
self.crypto.getRandomValues(array);
|
||
return Array.from(array, byte => chars[byte % chars.length]).join('');
|
||
};
|
||
const roomId = generateRoomId();
|
||
const password = secureGenerateId();
|
||
elements.roomId.value = roomId;
|
||
elements.password.value = password;
|
||
pendingRoomCreation = true;
|
||
window.justCreatedRoom = true;
|
||
|
||
// Auto-connect
|
||
elements.joinBtn.click();
|
||
}
|
||
|
||
elements.createRoomBtn.addEventListener('click', handleCreateRoom);
|
||
const syncTabCreateRoomBtn = document.getElementById('syncTabCreateRoomBtn');
|
||
if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
|
||
document.querySelector('.tab-btn[data-tab="tab-room"]').click();
|
||
handleCreateRoom();
|
||
});
|
||
|
||
elements.refreshRooms.addEventListener('click', () => {
|
||
if (elements.refreshRooms.disabled) return;
|
||
setRoomRefreshCooldown();
|
||
|
||
elements.publicRooms.replaceChildren();
|
||
const el = document.createElement('div');
|
||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;';
|
||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING_COOLDOWN', { seconds: Math.ceil(ROOM_LIST_REFRESH_COOLDOWN_MS / 1000) });
|
||
elements.publicRooms.appendChild(el);
|
||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||
});
|
||
|
||
elements.retryBtn.addEventListener('click', () => {
|
||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||
});
|
||
|
||
elements.targetTab.addEventListener('change', async () => {
|
||
const hint = document.getElementById('targetTabHint');
|
||
if (hint) hint.style.display = 'none';
|
||
|
||
const val = elements.targetTab.value;
|
||
const tabId = val ? parseInt(val) : null;
|
||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
|
||
await selectTargetTab(tabId, tabTitle);
|
||
});
|
||
|
||
if (elements.siteAccessRetry) {
|
||
elements.siteAccessRetry.addEventListener('click', async () => {
|
||
const tabId = normalizeTabId(elements.targetTab.value);
|
||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
|
||
if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) {
|
||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||
return;
|
||
}
|
||
const requestedOriginPattern = siteAccessOriginPattern;
|
||
const requestedTabId = siteAccessTabId;
|
||
elements.siteAccessRetry.disabled = true;
|
||
elements.targetTab.disabled = true;
|
||
try {
|
||
const granted = await requestOriginPermission(chrome, requestedOriginPattern);
|
||
if (granted !== true) return;
|
||
const selectedTabId = normalizeTabId(elements.targetTab.value);
|
||
// permissions.onAdded may already have completed activation, or a
|
||
// newer selection may have replaced this request while it was open.
|
||
if (!siteAccessBlocked || selectedTabId !== tabId
|
||
|| siteAccessTabId !== requestedTabId
|
||
|| siteAccessOriginPattern !== requestedOriginPattern) return;
|
||
await selectTargetTab(tabId, tabTitle);
|
||
} finally {
|
||
elements.siteAccessRetry.disabled = siteAccessBlocked
|
||
&& (!siteAccessOriginPattern || siteAccessTabId === null);
|
||
elements.targetTab.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
|
||
if (elements.forceSyncBtn.disabled) return;
|
||
|
||
const originalText = elements.forceSyncBtn.textContent;
|
||
elements.forceSyncBtn.disabled = true;
|
||
|
||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||
if (chrome.runtime.lastError || !status || !status.targetTabId) {
|
||
elements.forceSyncBtn.disabled = false;
|
||
return;
|
||
}
|
||
const tabId = normalizeTabId(status.targetTabId);
|
||
if (tabId === null) {
|
||
elements.forceSyncBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
const mode = elements.forceSyncMode.value;
|
||
let targetTime = null;
|
||
|
||
if (mode === 'jump-to-others') {
|
||
if (!localPeerId) {
|
||
showError(getMessage('ERR_IDENTITY_NOT_LOADED'));
|
||
elements.forceSyncBtn.disabled = false;
|
||
return;
|
||
}
|
||
const peers = status.peers || [];
|
||
const otherTimes = peers
|
||
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && p.currentTime !== '')
|
||
.map(p => Number(p.currentTime))
|
||
.filter(Number.isFinite);
|
||
|
||
if (otherTimes.length === 0) {
|
||
showError(getMessage('ERR_NO_PEERS_TIME'));
|
||
elements.forceSyncBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
otherTimes.sort((a, b) => a - b);
|
||
const mid = Math.floor(otherTimes.length / 2);
|
||
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
|
||
}
|
||
|
||
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? getMessage('BTN_STATE_SYNCING_GROUP', { time: formatTime(targetTime) }) : getMessage('BTN_STATE_SYNCING');
|
||
forceSyncDone = false;
|
||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||
const syncTimeoutMs = peerCount === 0 ? 3000 : 12000;
|
||
const forceSyncReset = () => {
|
||
// Don't unlock a button that's locked because we became a guest mid-flight —
|
||
// hcmGuestLocked is the source of truth for the lock state, and the next
|
||
// CONTROL_MODE update restores the correct label.
|
||
if (!forceSyncDone && !hcmGuestLocked && !siteAccessBlocked) {
|
||
elements.forceSyncBtn.disabled = false;
|
||
elements.forceSyncBtn.textContent = originalText;
|
||
}
|
||
};
|
||
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
|
||
|
||
const failForceSyncTime = () => {
|
||
if (forceSyncDone) return;
|
||
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
|
||
showError(getMessage('ERR_NO_VIDEO_TAB'));
|
||
forceSyncDone = true;
|
||
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
|
||
elements.forceSyncBtn.textContent = originalText;
|
||
};
|
||
|
||
const targetIsStillCurrent = () => new Promise(resolve => {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => {
|
||
if (chrome.runtime.lastError) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
|
||
});
|
||
});
|
||
|
||
const sendForceSync = async (time) => {
|
||
if (time === null || time === undefined) {
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
const target = Number(time);
|
||
if (!Number.isFinite(target)) {
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
if (!(await targetIsStillCurrent())) {
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
chrome.runtime.sendMessage({
|
||
type: 'CONTENT_EVENT',
|
||
action: EVENTS.FORCE_SYNC_PREPARE,
|
||
expectedTabId: tabId,
|
||
payload: { targetTime: target }
|
||
}, response => {
|
||
if (chrome.runtime.lastError || response?.status === 'stale_target') {
|
||
failForceSyncTime();
|
||
}
|
||
});
|
||
};
|
||
|
||
if (mode === 'jump-to-me') {
|
||
const retryQueryTime = async () => {
|
||
if (!(await targetIsStillCurrent())) {
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
sendForceSync(retryResponse.currentTime);
|
||
});
|
||
};
|
||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||
if (Number.isFinite(response?.currentTime)) {
|
||
sendForceSync(response.currentTime);
|
||
return;
|
||
}
|
||
if (chrome.runtime.lastError || !response) {
|
||
chrome.runtime.sendMessage({
|
||
type: 'INJECT_CONTENT_SCRIPT',
|
||
tabId,
|
||
expectedCurrentTabId: tabId
|
||
}, (injectResponse) => {
|
||
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
|
||
if (injectResponse?.status === 'host_permission_required') {
|
||
showSiteAccessNotice(
|
||
injectResponse.host,
|
||
injectResponse.originPattern,
|
||
injectResponse.tabId
|
||
);
|
||
}
|
||
failForceSyncTime();
|
||
return;
|
||
}
|
||
setTimeout(retryQueryTime, 500);
|
||
});
|
||
return;
|
||
}
|
||
setTimeout(retryQueryTime, 500);
|
||
});
|
||
} else {
|
||
sendForceSync(targetTime);
|
||
}
|
||
});
|
||
|
||
elements.playBtn.addEventListener('click', () => {
|
||
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
|
||
if (!elements.targetTab.value) {
|
||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||
return;
|
||
}
|
||
elements.playBtn.textContent = getMessage('BTN_STATE_PLAYING');
|
||
elements.playBtn.disabled = true;
|
||
chrome.runtime.sendMessage({
|
||
type: 'CONTENT_EVENT',
|
||
action: EVENTS.PLAY,
|
||
payload: {}
|
||
}, (response) => {
|
||
if (response && response.status === 'ok_solo') {
|
||
elements.playBtn.textContent = getMessage('BTN_PLAY');
|
||
if (!hcmGuestLocked && !siteAccessBlocked) elements.playBtn.disabled = false;
|
||
}
|
||
});
|
||
// Safety reset: restore button after 2.5s in case no peers respond
|
||
setTimeout(() => {
|
||
if (elements.playBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
|
||
elements.playBtn.textContent = getMessage('BTN_PLAY');
|
||
elements.playBtn.disabled = false;
|
||
}
|
||
}, 2500);
|
||
});
|
||
|
||
elements.pauseBtn.addEventListener('click', () => {
|
||
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
|
||
if (!elements.targetTab.value) {
|
||
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
|
||
return;
|
||
}
|
||
elements.pauseBtn.textContent = getMessage('BTN_STATE_PAUSING');
|
||
elements.pauseBtn.disabled = true;
|
||
chrome.runtime.sendMessage({
|
||
type: 'CONTENT_EVENT',
|
||
action: EVENTS.PAUSE,
|
||
payload: {}
|
||
}, (response) => {
|
||
if (response && response.status === 'ok_solo') {
|
||
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
|
||
if (!hcmGuestLocked && !siteAccessBlocked) elements.pauseBtn.disabled = false;
|
||
}
|
||
});
|
||
// Safety reset: restore button after 2.5s in case no peers respond
|
||
setTimeout(() => {
|
||
if (elements.pauseBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
|
||
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
|
||
elements.pauseBtn.disabled = false;
|
||
}
|
||
}, 2500);
|
||
});
|
||
|
||
function simulateRemoteSeek({ delta = null, targetTime = null }) {
|
||
chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta, targetTime }, (res) => {
|
||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||
showToast(res?.message || 'Remote seek failed', 'error');
|
||
return;
|
||
}
|
||
showToast(`Remote seek -> ${formatTime(res.targetTime)}`, 'success', 1200);
|
||
refreshDebugInfo();
|
||
});
|
||
}
|
||
|
||
if (elements.remoteSeekBack) {
|
||
elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek({ delta: -30 }));
|
||
}
|
||
if (elements.remoteSeekForward) {
|
||
elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek({ delta: 30 }));
|
||
}
|
||
if (elements.remoteSeekFiveMin) {
|
||
elements.remoteSeekFiveMin.addEventListener('click', () => simulateRemoteSeek({ targetTime: 300 }));
|
||
}
|
||
|
||
elements.clearLogs.addEventListener('click', () => {
|
||
chrome.runtime.sendMessage({ type: 'CLEAR_LOGS' }, () => {
|
||
elements.logList.innerHTML = '';
|
||
});
|
||
});
|
||
|
||
if (elements.regenId) {
|
||
elements.regenId.addEventListener('click', () => {
|
||
elements.regenId.disabled = true;
|
||
chrome.runtime.sendMessage({ type: 'REGENERATE_ID' }, (res) => {
|
||
elements.regenId.disabled = false;
|
||
if (chrome.runtime.lastError || !res || !res.peerId) {
|
||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Failed to regenerate identity', 'error');
|
||
return;
|
||
}
|
||
showToast(getMessage('TOAST_ID_REGENERATED') || 'Identity regenerated — reconnecting…', 'success', 3000);
|
||
});
|
||
});
|
||
}
|
||
|
||
elements.copyInvite.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||
const original = elements.copyInvite.textContent;
|
||
elements.copyInvite.textContent = '✓';
|
||
elements.copyInvite.classList.add('copy-success');
|
||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||
spawnLeafBurst(elements.copyInvite);
|
||
setTimeout(() => {
|
||
elements.copyInvite.textContent = original;
|
||
elements.copyInvite.classList.remove('copy-success');
|
||
}, 1500);
|
||
}).catch(() => {
|
||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||
});
|
||
});
|
||
|
||
if (elements.syncTabCopyInvite) {
|
||
elements.syncTabCopyInvite.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||
const original = elements.syncTabCopyInvite.textContent;
|
||
elements.syncTabCopyInvite.textContent = '✓ Copied';
|
||
elements.syncTabCopyInvite.classList.add('copy-success');
|
||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||
spawnLeafBurst(elements.syncTabCopyInvite);
|
||
setTimeout(() => {
|
||
elements.syncTabCopyInvite.textContent = original;
|
||
elements.syncTabCopyInvite.classList.remove('copy-success');
|
||
}, 1500);
|
||
});
|
||
});
|
||
}
|
||
|
||
if (elements.cancelLobbyBtn) {
|
||
elements.cancelLobbyBtn.addEventListener('click', () => {
|
||
chrome.runtime.sendMessage({ type: 'CANCEL_EPISODE_LOBBY' }, (response) => {
|
||
if (response && response.status === 'ok') {
|
||
showToast(getMessage('TOAST_LOBBY_SKIPPED'), 'info');
|
||
if (elements.episodeLobbyCard) {
|
||
elements.episodeLobbyCard.style.display = 'none';
|
||
}
|
||
} else {
|
||
showToast(getMessage('TOAST_LOBBY_SKIP_FAILED'), 'error');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Logs & Status ---
|
||
async function refreshLogs() {
|
||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||
if (elements.logList) {
|
||
if (!logs || logs.length === 0) {
|
||
renderEmpty(elements.logList, 'logs');
|
||
return;
|
||
}
|
||
elements.logList.innerHTML = '';
|
||
logs.forEach(log => {
|
||
const entry = document.createElement('div');
|
||
entry.className = `log-entry log-${log.type}`;
|
||
const timeStr = log.timestamp?.split('T')?.[1]?.split('.')[0] || '?';
|
||
entry.textContent = `[${timeStr}] ${log.message}`;
|
||
elements.logList.appendChild(entry);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((msg) => {
|
||
if (!msg) return;
|
||
if (msg.type === 'LOG_UPDATE') {
|
||
refreshLogs();
|
||
if (msg.log && msg.log.type === 'error') {
|
||
const errMsg = msg.log.message || '';
|
||
const isConnErr = typeof errMsg === 'string' && (
|
||
errMsg.toLowerCase().includes('connection') ||
|
||
errMsg.toLowerCase().includes('websocket')
|
||
);
|
||
if (isConnErr) {
|
||
pendingConnectionErrorMsg = msg.log.message;
|
||
if (!connectionErrorTimer) {
|
||
connectionErrorTimer = setTimeout(() => {
|
||
if (pendingConnectionErrorMsg) {
|
||
showError(pendingConnectionErrorMsg);
|
||
}
|
||
connectionErrorTimer = null;
|
||
pendingConnectionErrorMsg = null;
|
||
}, 5000);
|
||
}
|
||
} else {
|
||
showError(msg.log.message);
|
||
}
|
||
}
|
||
} else if (msg.type === 'ACTION_UPDATE') {
|
||
const state = msg.state;
|
||
if (state && state.senderId && state.senderId !== 'You') {
|
||
const actionNames = {
|
||
'play': getMessage('NOTIF_PLAY'),
|
||
'pause': getMessage('NOTIF_PAUSE'),
|
||
'seek': getMessage('NOTIF_SEEK'),
|
||
'force_sync_prepare': getMessage('NOTIF_FORCE_PREPARE'),
|
||
'force_sync_execute': getMessage('NOTIF_FORCE_EXECUTE')
|
||
};
|
||
const action = actionNames[state.action] || state.action;
|
||
let displayName = state.senderId;
|
||
const peer = lastKnownPeers.find(p => (p.peerId || p) === state.senderId);
|
||
if (peer && peer.username) displayName = peer.username;
|
||
showToast(getMessage('TOAST_PEER_ACTION', { name: displayName, action }), 'info', 2000);
|
||
}
|
||
|
||
if (state && (state.action === 'play' || state.action === 'pause')) {
|
||
const btn = state.action === 'play' ? elements.playBtn : elements.pauseBtn;
|
||
if (btn && btn.disabled) {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
const peerCount = res && res.peers ? res.peers.length : 1;
|
||
if (state.acks && state.acks.length >= peerCount) {
|
||
btn.textContent = getMessage('BTN_STATE_SYNCED');
|
||
setTimeout(() => {
|
||
if (!hcmGuestLocked && !siteAccessBlocked) btn.disabled = false;
|
||
btn.textContent = state.action === 'play' ? getMessage('BTN_PLAY') : getMessage('BTN_PAUSE');
|
||
}, 2000);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (state && state.action === 'force_sync_execute') {
|
||
forceSyncDone = true;
|
||
if (forceSyncResetTimer) {
|
||
clearTimeout(forceSyncResetTimer);
|
||
forceSyncResetTimer = null;
|
||
}
|
||
if (elements.forceSyncBtn) {
|
||
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
|
||
elements.forceSyncBtn.textContent = getMessage('BTN_STATE_SYNCED');
|
||
setTimeout(() => {
|
||
elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
|
||
}, 2000);
|
||
}
|
||
}
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
|
||
});
|
||
} else if (msg.type === 'PEER_UPDATE') {
|
||
updatePeerList(msg.peers);
|
||
if (msg.peers) detectPeerChanges(msg.peers);
|
||
} else if (msg.type === 'CONTROL_MODE') {
|
||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||
updateHostControlUI({ controlMode: msg.controlMode, amHost: msg.amHost, amController: msg.amController, controllers: msg.controllers, hostPeerId: msg.hostPeerId, hostControlSupported: msg.hostControlSupported, coHostSupported: msg.coHostSupported, inRoom });
|
||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||
if (msg.status === 'connected' || msg.status === 'disconnected') {
|
||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||
}
|
||
if (msg.status === 'connected') {
|
||
clearConnectionErrorTimer();
|
||
}
|
||
if (msg.status !== 'reconnecting') {
|
||
applyConnectionStatus(msg.status);
|
||
reconnectSlowMode = false;
|
||
}
|
||
if (msg.status === 'connected') {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (!res) return;
|
||
if (res.peers) updatePeerList(res.peers);
|
||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||
updatePingDisplay(res.ping);
|
||
updateHostControlUI({ controlMode: res.controlMode, amHost: res.amHost, amController: res.amController, controllers: res.controllers, hostPeerId: res.hostPeerId, hostControlSupported: res.hostControlSupported, coHostSupported: res.coHostSupported, inRoom: true });
|
||
});
|
||
}
|
||
if (msg.status === 'disconnected') {
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
}
|
||
if (msg.status === 'reconnecting') {
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (chrome.runtime.lastError) return;
|
||
if (res && res.reconnectSlowMode !== undefined) reconnectSlowMode = res.reconnectSlowMode;
|
||
applyConnectionStatus(msg.status);
|
||
if (res && res.reconnectAttempts !== undefined) {
|
||
if (elements.connText) {
|
||
elements.connText.textContent = `${getMessage('STATUS_RECONNECTING')} (${res.reconnectAttempts})`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} else if (msg.type === 'TARGET_TAB_READY') {
|
||
refreshTargetAccessState().catch(() => {});
|
||
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
|
||
refreshTargetAccessState().catch(() => {});
|
||
} else if (msg.type === 'TARGET_TAB_CLEARED') {
|
||
refreshTargetAccessState().catch(() => {});
|
||
} else if (msg.type === 'PING_UPDATE') {
|
||
updatePingDisplay(msg.ping);
|
||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||
updateHistory(msg.history);
|
||
} else if (msg.type === 'ROOM_LIST') {
|
||
updateRoomList(msg.rooms);
|
||
} else if (msg.type === 'JOIN_STATUS') {
|
||
isProcessingConnection = false;
|
||
if (joinBtnTimeout) {
|
||
clearTimeout(joinBtnTimeout);
|
||
joinBtnTimeout = null;
|
||
}
|
||
elements.joinBtn.disabled = false;
|
||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||
|
||
if (msg.success) {
|
||
// Final confirmation of join from background
|
||
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
|
||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||
if (syncTabBtn) syncTabBtn.click();
|
||
showSelectVideoHint();
|
||
});
|
||
} else {
|
||
// Join failed: reset UI state
|
||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||
elements.roomId.value = '';
|
||
elements.password.value = '';
|
||
updateUI(null, null);
|
||
}
|
||
} else if (msg.type === 'LOBBY_UPDATE') {
|
||
// Episode lobby state changed
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (res && res.peers) {
|
||
updateLobbyUI(msg.lobby, res.peers);
|
||
} else {
|
||
updateLobbyUI(msg.lobby, []);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
elements.copyLogs.addEventListener('click', () => {
|
||
const utcNow = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||
const userAgent = String(navigator.userAgent || '');
|
||
|
||
const safe = (val, fb) => (val != null ? val : fb);
|
||
|
||
Promise.all([
|
||
new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, resolve)),
|
||
new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_LOGS' }, resolve)),
|
||
new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_HISTORY' }, resolve))
|
||
]).then(([status, logs, history]) => {
|
||
status = status || {};
|
||
logs = logs || [];
|
||
history = history || [];
|
||
|
||
const videoPromise = (status && status.targetTabId)
|
||
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve))
|
||
: Promise.resolve(null);
|
||
|
||
videoPromise.then(rawVideo => {
|
||
const vs = rawVideo || {};
|
||
const lines = [];
|
||
|
||
// ── Header ──
|
||
const verStr = safe(status.version, '?');
|
||
lines.push('# KoalaSync Debug Report');
|
||
lines.push(`> **v${verStr}** | ${utcNow}`);
|
||
lines.push('');
|
||
|
||
// ── System ──
|
||
lines.push('## System');
|
||
lines.push(`- **Protocol:** ${safe(status.protocolVersion, '?')}`);
|
||
lines.push(`- **Peer ID:** \`${safe(status.peerId, '?')}\``);
|
||
lines.push(`- **User Agent:** ${userAgent}`);
|
||
lines.push('');
|
||
|
||
// ── Tab ──
|
||
if (rawVideo) {
|
||
lines.push('## Tab');
|
||
if (vs.pageTitle) lines.push(`- **Title:** ${vs.pageTitle}`);
|
||
if (vs.url) lines.push(`- **URL:** ${vs.url}`);
|
||
if (vs.platform) lines.push(`- **Platform:** ${safe(vs.platform, '?')}`);
|
||
lines.push(`- **Video Count:** ${safe(vs.videoCount, 0)} | **Shadow DOM:** ${vs.inShadowDom ? 'YES' : 'NO'}`);
|
||
lines.push('');
|
||
|
||
// Multi-video overview
|
||
const videos = Array.isArray(vs.allVideos) ? vs.allVideos : [];
|
||
if (videos.length > 1) {
|
||
lines.push('### All Videos on Page');
|
||
lines.push('');
|
||
lines.push('| # | Resolution | Muted | Paused | Ready | Duration | Selected |');
|
||
lines.push('|---|------------|-------|--------|-------|----------|----------|');
|
||
const rl = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
|
||
for (const v of videos) {
|
||
if (!v) continue;
|
||
const sel = v.selected ? ' **\u2190 TARGET**' : '';
|
||
const dim = `${safe(v.width, '?')}x${safe(v.height, '?')}`;
|
||
const rs = (v.readyState != null && v.readyState >= 0 && v.readyState <= 4) ? rl[v.readyState] : '?';
|
||
lines.push(`| ${safe(v.index, '?')} | ${dim} | ${safe(v.muted, '?')} | ${safe(v.paused, '?')} | ${rs} | ${safe(v.duration, 0)}s |${sel} |`);
|
||
}
|
||
lines.push('');
|
||
}
|
||
}
|
||
|
||
// ── Connection ──
|
||
lines.push('## Connection');
|
||
const st = safe(status.status, 'disconnected');
|
||
const emoji = st === 'connected' ? '\uD83D\uDFE2'
|
||
: st === 'connecting' ? '\uD83D\uDFE1'
|
||
: st === 'reconnecting' ? '\uD83D\uDFE0'
|
||
: '\uD83D\uDD34';
|
||
lines.push(`- **Status:** ${emoji} ${st}`);
|
||
lines.push(`- **Server:** \`${safe(status.serverUrl, '?')}\``);
|
||
lines.push(`- **Ping:** ${status.ping != null ? `${status.ping}ms` : '-'}`);
|
||
if (status.roomId) {
|
||
lines.push(`- **Room:** \`${status.roomId}\``);
|
||
const peers = Array.isArray(status.peers) ? status.peers : [];
|
||
const peerNames = peers.map(p => (p && (p.username || p.peerId)) || '?').join(', ');
|
||
lines.push(`- **Peers (${peers.length}):** ${peers.length > 0 ? peerNames : 'none'}`);
|
||
} else {
|
||
lines.push('- **Room:** none');
|
||
}
|
||
if (safe(status.reconnectAttempts, 0) > 0) lines.push(`- **Reconnect Attempts:** ${status.reconnectAttempts}`);
|
||
lines.push('');
|
||
|
||
// ── Video ──
|
||
lines.push('## Video');
|
||
if (!rawVideo) {
|
||
lines.push('- *No tab selected / communication failed*');
|
||
} else if (!vs.found) {
|
||
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
|
||
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
|
||
if (vs.inShadowDom != null) lines.push(`- **Shadow DOM:** ${vs.inShadowDom ? 'YES (checked)' : 'NO'}`);
|
||
if (vs.metadata) {
|
||
if (vs.metadata.title) lines.push(`- **MediaSession Title:** "${vs.metadata.title}"`);
|
||
if (vs.metadata.artist) lines.push(`- **MediaSession Artist:** "${vs.metadata.artist}"`);
|
||
if (vs.metadata.album) lines.push(`- **MediaSession Album:** "${vs.metadata.album}"`);
|
||
}
|
||
} else {
|
||
const timeStr = (typeof vs.currentTime === 'number' ? vs.currentTime.toFixed(2) : '?') + 's / ' +
|
||
(typeof vs.duration === 'number' ? vs.duration.toFixed(2) : '?') + 's';
|
||
const readyLabel = safe(vs.readyStateLabel, '?');
|
||
const readyOk = safe(vs.readyState, -1) >= 3;
|
||
const netLabel = safe(vs.networkStateLabel, '?');
|
||
const dimOk = safe(vs.videoWidth, 0) > 0 && safe(vs.videoHeight, 0) > 0;
|
||
|
||
lines.push(`- **State:** ${vs.paused ? 'PAUSED' : 'PLAYING'}`);
|
||
lines.push(`- **Time:** ${timeStr}`);
|
||
lines.push(`- **ReadyState:** ${readyOk ? '\u2705' : '\u26A0\uFE0F'} ${safe(vs.readyState, '?')} (${readyLabel})`);
|
||
lines.push(`- **Network:** ${safe(vs.networkState, '?')} (${netLabel})`);
|
||
lines.push(`- **Buffered:** ${safe(vs.buffered, '?')}`);
|
||
if (vs.nativeCurrentTime != null || vs.nativeDuration != null) {
|
||
lines.push(`- **Native Time:** ${safe(vs.nativeCurrentTime, '?')}s / ${safe(vs.nativeDuration, '?')}s`);
|
||
}
|
||
if (vs.siteQuirk) {
|
||
const quirk = vs.siteQuirk;
|
||
const label = quirk.name || quirk.key || 'site';
|
||
if (quirk.timeline) {
|
||
lines.push(`- **${label} Timeline:** ${safe(quirk.timeline.current, '?')}s / ${safe(quirk.timeline.duration, '?')}s`);
|
||
}
|
||
const candidates = Array.isArray(quirk.timelineCandidates) ? quirk.timelineCandidates : [];
|
||
if (candidates.length > 0) {
|
||
lines.push(`- **${label} Timeline Candidates:**`);
|
||
candidates.forEach(c => lines.push(` - ${safe(c.source, '?')}: ${safe(c.current, '?')}s / ${safe(c.duration, '?')}s`));
|
||
}
|
||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons : [];
|
||
if (buttons.length > 0) {
|
||
lines.push(`- **${label} Buttons:**`);
|
||
buttons.forEach(label => lines.push(` - ${label}`));
|
||
}
|
||
}
|
||
lines.push(`- **Dimensions:** ${safe(vs.videoWidth, '?')}x${safe(vs.videoHeight, '?')}${dimOk ? '' : ' \u26A0\uFE0F 0x0'}`);
|
||
lines.push(`- **Muted:** ${safe(vs.muted, '?')} | **Volume:** ${safe(vs.volume, '?')} | **Speed:** ${safe(vs.playbackRate, '?')}x`);
|
||
lines.push(`- **Seeking:** ${safe(vs.seeking, '?')} | **Ended:** ${safe(vs.ended, '?')} | **Loop:** ${safe(vs.loop, '?')}`);
|
||
if (vs.error && vs.error.code != null) {
|
||
lines.push(`- **Error:** code=${vs.error.code}, msg="${safe(vs.error.message, 'none')}"`);
|
||
}
|
||
lines.push(`- **ID:** \`${safe(vs.id, '')}\` | **Class:** \`${safe(vs.className, '')}\``);
|
||
lines.push(`- **Source:** \`${safe(vs.currentSrc, safe(vs.src, 'none'))}\``);
|
||
if (vs.metadata) {
|
||
if (vs.metadata.title) lines.push(`- **MediaSession Title:** "${vs.metadata.title}"`);
|
||
if (vs.metadata.artist) lines.push(`- **MediaSession Artist:** "${vs.metadata.artist}"`);
|
||
if (vs.metadata.album) lines.push(`- **MediaSession Album:** "${vs.metadata.album}"`);
|
||
}
|
||
if (vs.dataAttributes) {
|
||
const keys = Object.keys(vs.dataAttributes);
|
||
if (keys.length > 0) {
|
||
lines.push('- **Data Attributes:**');
|
||
for (const k of keys) lines.push(` - \`${k}\` = "${safe(vs.dataAttributes[k], '')}"`);
|
||
}
|
||
}
|
||
}
|
||
lines.push('');
|
||
|
||
// ── Action History (last 20) ──
|
||
lines.push('## Action History (last 20)');
|
||
if (history && history.length > 0) {
|
||
const recent = history.slice(0, 20).reverse();
|
||
lines.push('```');
|
||
for (const h of recent) {
|
||
if (!h) continue;
|
||
const ts = safe(h.timestamp, '');
|
||
const evt = safe(h.action, '?');
|
||
const from = h.senderId ? ` (${h.senderId})` : (h.peerId ? ` (${h.peerId})` : '');
|
||
const extra = h.detail ? ` \u2192 ${h.detail}` : '';
|
||
lines.push(`[${ts}] ${evt}${from}${extra}`);
|
||
}
|
||
lines.push('```');
|
||
} else {
|
||
lines.push('*No history entries*');
|
||
}
|
||
lines.push('');
|
||
|
||
// ── Logs (last 50) ──
|
||
lines.push('## Logs (last 50)');
|
||
if (logs && logs.length > 0) {
|
||
const recent = logs.slice(0, 50).reverse();
|
||
lines.push('```');
|
||
for (const l of recent) {
|
||
if (!l) continue;
|
||
lines.push(`[${safe(l.timestamp, '')}] [${safe(l.type, '?')}] ${safe(l.message, '')}`);
|
||
}
|
||
lines.push('```');
|
||
} else {
|
||
lines.push('*No log entries*');
|
||
}
|
||
|
||
const text = lines.join('\n');
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const original = elements.copyLogs.textContent;
|
||
elements.copyLogs.textContent = getMessage('TOAST_LOGS_COPIED');
|
||
setTimeout(() => { elements.copyLogs.textContent = original; }, 2000);
|
||
|
||
// Construct rich multiline toast summary
|
||
const verStr = safe(status.version, '?');
|
||
const logsCount = logs.length;
|
||
const historyCount = history.length;
|
||
const peersCount = Array.isArray(status.peers) ? status.peers.length : 0;
|
||
|
||
let summary = `📋 Debug Report Copied!\n`;
|
||
summary += `• Version: v${verStr}\n`;
|
||
summary += `• System Logs: ${logsCount} entries\n`;
|
||
summary += `• Sync Actions: ${historyCount} captured\n`;
|
||
if (status.roomId) {
|
||
summary += `• Peers in Room: ${peersCount}\n`;
|
||
}
|
||
if (rawVideo && vs.found) {
|
||
const stateStr = vs.paused === true ? 'Paused' : (vs.paused === false ? 'Playing' : 'Unknown');
|
||
const timeStr = typeof vs.currentTime === 'number' ? `${Math.round(vs.currentTime)}s` : '?';
|
||
summary += `• Video: ${stateStr} at ${timeStr}\n`;
|
||
}
|
||
summary += `Paste it in markdown to view!`;
|
||
|
||
showToast(summary, 'success', 5000);
|
||
}).catch(() => {
|
||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
function refreshDebugInfo() {
|
||
// Only refresh if Dev tab is visible
|
||
const devTab = document.getElementById('tab-dev');
|
||
if (!devTab || devTab.style.display === 'none') return;
|
||
|
||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||
if (!res || !res.targetTabId) {
|
||
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB');
|
||
return;
|
||
}
|
||
|
||
// Request direct state from the content script via background
|
||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => {
|
||
if (!state || (!state.found && state.error)) {
|
||
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_COMM_FAIL');
|
||
return;
|
||
}
|
||
|
||
if (elements.videoDebug) {
|
||
elements.videoDebug.innerHTML = '';
|
||
|
||
const addField = (label, value, color = null) => {
|
||
const row = document.createElement('div');
|
||
row.style.marginBottom = '4px';
|
||
if (color) row.style.color = color;
|
||
|
||
const b = document.createElement('b');
|
||
b.textContent = `${label}: `;
|
||
b.style.color = 'var(--text-muted)';
|
||
|
||
const span = document.createElement('span');
|
||
span.textContent = value;
|
||
span.style.wordBreak = 'break-all';
|
||
|
||
row.appendChild(b);
|
||
row.appendChild(span);
|
||
elements.videoDebug.appendChild(row);
|
||
};
|
||
|
||
const addSection = (title) => {
|
||
const div = document.createElement('div');
|
||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 10px; text-transform: uppercase;';
|
||
div.textContent = title;
|
||
elements.videoDebug.appendChild(div);
|
||
};
|
||
|
||
if (!state.found) {
|
||
// No video found — show diagnostic info
|
||
addSection(getMessage('DEBUG_SECTION_VIDEO_DETECTION'));
|
||
const notFound = document.createElement('div');
|
||
notFound.style.cssText = 'color: var(--status-error-text); font-weight: 700; margin-bottom: 8px;';
|
||
notFound.textContent = getMessage('DEBUG_VIDEO_NOT_FOUND');
|
||
elements.videoDebug.appendChild(notFound);
|
||
|
||
addField('Platform', state.platform || '?', 'var(--accent)');
|
||
addField('Page Title', state.pageTitle || '?');
|
||
addField('URL', state.url || '?');
|
||
addField('Video Tags', String(state.videoCount || 0));
|
||
addField('Shadow DOM', state.inShadowDom ? getMessage('DEBUG_YES_CHECKED') : getMessage('DEBUG_NO'));
|
||
|
||
if (state.metadata) {
|
||
addSection(getMessage('DEBUG_SECTION_MEDIA_SESSION'));
|
||
addField('Title', state.metadata.title || getMessage('DEBUG_NA'));
|
||
addField('Artist', state.metadata.artist || getMessage('DEBUG_NA'));
|
||
addField('Album', state.metadata.album || getMessage('DEBUG_NA'));
|
||
}
|
||
|
||
const hint = document.createElement('div');
|
||
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: color-mix(in oklch, var(--warning), transparent 88%); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 10px; color: var(--text);';
|
||
hint.textContent = getMessage('DEBUG_VIDEO_HINT');
|
||
elements.videoDebug.appendChild(hint);
|
||
return;
|
||
}
|
||
|
||
// Video found — full debug
|
||
addSection(getMessage('DEBUG_SECTION_PLAYBACK'));
|
||
addField('State', state.paused ? getMessage('DEBUG_STATE_PAUSED') : getMessage('DEBUG_STATE_PLAYING'), state.paused ? 'var(--text-muted)' : 'var(--status-success-text)');
|
||
addField('Time', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
|
||
addField('ReadyState', `${state.readyState} (${state.readyStateLabel || '?'})`,
|
||
state.readyState >= 3 ? 'var(--status-success-text)' : 'var(--status-warning-text)');
|
||
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
|
||
state.networkState === 1 ? 'var(--status-success-text)' : state.networkState === 3 ? 'var(--status-error-text)' : 'var(--text-muted)');
|
||
addField('Buffered', state.buffered || '?');
|
||
if (state.nativeCurrentTime != null || state.nativeDuration != null) {
|
||
addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`);
|
||
}
|
||
if (state.siteQuirk) {
|
||
const quirk = state.siteQuirk;
|
||
const label = quirk.name || quirk.key || 'Site';
|
||
if (quirk.timeline) {
|
||
addField(`${label} Timeline`, `${quirk.timeline.current ?? '?'}s / ${quirk.timeline.duration ?? '?'}s`);
|
||
}
|
||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
|
||
if (buttons) addField(`${label} Buttons`, buttons);
|
||
}
|
||
addSection(getMessage('DEBUG_SECTION_PROPERTIES'));
|
||
addField('Seeking', String(state.seeking));
|
||
addField('Ended', String(state.ended));
|
||
addField('Loop', String(state.loop));
|
||
addField('Muted', String(state.muted));
|
||
addField('Volume', String(state.volume));
|
||
addField('Speed', `${state.playbackRate}x`);
|
||
|
||
addSection(getMessage('DEBUG_SECTION_DIMENSIONS'));
|
||
const dimsOk = state.videoWidth > 0 && state.videoHeight > 0;
|
||
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? 'var(--status-success-text)' : 'var(--status-error-text)');
|
||
if (!dimsOk) {
|
||
const dimHint = document.createElement('div');
|
||
dimHint.style.cssText = 'color: var(--status-warning-text); font-size: 10px; margin: 2px 0 6px 12px;';
|
||
dimHint.textContent = getMessage('DEBUG_DIMENSIONS_HINT');
|
||
elements.videoDebug.appendChild(dimHint);
|
||
}
|
||
|
||
if (state.error) {
|
||
addSection(getMessage('DEBUG_SECTION_MEDIA_ERROR'));
|
||
addField('Code', String(state.error.code), 'var(--status-error-text)');
|
||
addField('Message', state.error.message || '?', 'var(--status-error-text)');
|
||
}
|
||
|
||
addSection(getMessage('DEBUG_SECTION_DETECTION'));
|
||
addField('Platform', state.platform || '?', 'var(--accent)');
|
||
addField('Video Count', String(state.videoCount || 0));
|
||
addField('Shadow DOM', state.inShadowDom ? getMessage('DEBUG_YES') : getMessage('DEBUG_NO'));
|
||
|
||
addSection(getMessage('DEBUG_SECTION_IDENTIFICATION'));
|
||
addField('URL', state.url);
|
||
addField('ID', state.id);
|
||
addField('CLASS', state.className);
|
||
|
||
addSection(getMessage('DEBUG_SECTION_MEDIA_SOURCE'));
|
||
addField('CurrentSrc', state.currentSrc);
|
||
addField('Src', state.src);
|
||
|
||
if (state.metadata) {
|
||
addSection(getMessage('DEBUG_SECTION_MEDIA_SESSION'));
|
||
addField('Title', state.metadata.title || getMessage('DEBUG_NA'));
|
||
addField('Artist', state.metadata.artist || getMessage('DEBUG_NA'));
|
||
addField('Album', state.metadata.album || getMessage('DEBUG_NA'));
|
||
}
|
||
|
||
if (state.dataAttributes && Object.keys(state.dataAttributes).length > 0) {
|
||
addSection(getMessage('DEBUG_SECTION_DATA_ATTRIBUTES'));
|
||
for (const [key, val] of Object.entries(state.dataAttributes)) {
|
||
addField(key.replace('data-', '').toUpperCase(), val);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
init();
|
||
popupIntervals.push(setInterval(() => {
|
||
if (isDevTabVisible) refreshLogs();
|
||
}, 5000));
|
||
|
||
window.addEventListener('unload', () => {
|
||
stopInterpolation();
|
||
popupIntervals.forEach(clearInterval);
|
||
popupIntervals = [];
|
||
if (joinBtnTimeout) {
|
||
clearTimeout(joinBtnTimeout);
|
||
joinBtnTimeout = null;
|
||
}
|
||
if (forceSyncResetTimer) {
|
||
clearTimeout(forceSyncResetTimer);
|
||
forceSyncResetTimer = null;
|
||
}
|
||
if (roomListRefreshTimer) {
|
||
clearTimeout(roomListRefreshTimer);
|
||
roomListRefreshTimer = null;
|
||
}
|
||
if (roomListRefreshInterval) {
|
||
clearInterval(roomListRefreshInterval);
|
||
roomListRefreshInterval = null;
|
||
}
|
||
});
|
||
|
||
// --- Episode Lobby UI ---
|
||
function updateLobbyUI(lobby, peers) {
|
||
if (!elements.episodeLobbyCard) return;
|
||
|
||
if (!lobby) {
|
||
elements.episodeLobbyCard.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
elements.episodeLobbyCard.style.display = 'block';
|
||
elements.lobbyTitle.textContent = getMessage('LOBBY_WAITING_FOR', { title: lobby.expectedTitle });
|
||
|
||
// Build peer readiness list. Peer usernames are remote-controlled input, so
|
||
// every node is built with the DOM API and filled via textContent.
|
||
const readySet = new Set(lobby.readyPeers || []);
|
||
const peerItems = [];
|
||
|
||
if (peers && peers.length > 0) {
|
||
peers.forEach(p => {
|
||
const pId = typeof p === 'object' ? p.peerId : p;
|
||
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
|
||
const avatar = getAvatarForName(pName);
|
||
const isReady = readySet.has(pId);
|
||
const icon = isReady ? '✅' : '⏳';
|
||
const label = isReady ? getMessage('LABEL_LOBBY_PEER_READY') : getMessage('LABEL_LOBBY_PEER_LOADING');
|
||
const badgeClass = isReady ? 'badge-ready' : 'badge-loading';
|
||
|
||
const item = document.createElement('span');
|
||
item.className = 'lobby-peer-item';
|
||
item.appendChild(document.createTextNode(`${icon} ${avatar} ${pName} `));
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = `badge ${badgeClass}`;
|
||
badge.textContent = label;
|
||
item.appendChild(badge);
|
||
|
||
peerItems.push(item);
|
||
});
|
||
}
|
||
|
||
if (peerItems.length > 0 && elements.lobbyPeerStatus) {
|
||
elements.lobbyPeerStatus.textContent = '';
|
||
peerItems.forEach((item, index) => {
|
||
if (index > 0) elements.lobbyPeerStatus.appendChild(document.createTextNode(' '));
|
||
elements.lobbyPeerStatus.appendChild(item);
|
||
});
|
||
} else if (elements.lobbyPeerStatus) {
|
||
elements.lobbyPeerStatus.textContent = getMessage('LOBBY_WAITING_PEERS');
|
||
}
|
||
|
||
// Show elapsed time
|
||
if (lobby.createdAt && elements.lobbyPeerStatus) {
|
||
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
|
||
const timerSpan = document.createElement('span');
|
||
timerSpan.style.cssText = 'font-size: 10px; color: var(--text-muted); margin-left: 6px; display: inline-block; vertical-align: middle;';
|
||
timerSpan.textContent = `(${elapsed}s)`;
|
||
elements.lobbyPeerStatus.appendChild(timerSpan);
|
||
}
|
||
}
|
||
|
||
// --- Onboarding Tour ---
|
||
const onboardingSteps = [
|
||
{
|
||
icon: '👋',
|
||
get title() { return getMessage('ONBOARDING_1_TITLE'); },
|
||
get text() { return getMessage('ONBOARDING_1_TEXT'); },
|
||
targetTab: 'tab-room',
|
||
targetSelector: null
|
||
},
|
||
{
|
||
icon: '🏠',
|
||
get title() { return getMessage('ONBOARDING_2_TITLE'); },
|
||
get text() { return getMessage('ONBOARDING_2_TEXT'); },
|
||
targetTab: 'tab-room',
|
||
targetSelector: '#createRoomBtn'
|
||
},
|
||
{
|
||
icon: '🎬',
|
||
get title() { return getMessage('ONBOARDING_3_TITLE'); },
|
||
get text() { return getMessage('ONBOARDING_3_TEXT'); },
|
||
targetTab: 'tab-sync',
|
||
targetSelector: '#targetTab'
|
||
},
|
||
{
|
||
icon: '⚙️',
|
||
get title() { return getMessage('ONBOARDING_4_TITLE'); },
|
||
get text() { return getMessage('ONBOARDING_4_TEXT'); },
|
||
targetTab: 'tab-settings',
|
||
targetSelector: '#username'
|
||
},
|
||
{
|
||
icon: '🍿',
|
||
get title() { return getMessage('ONBOARDING_5_TITLE'); },
|
||
get text() { return getMessage('ONBOARDING_5_TEXT'); },
|
||
targetTab: 'tab-room',
|
||
targetSelector: null
|
||
}
|
||
];
|
||
|
||
function showSelectVideoHint() {
|
||
const hint = document.getElementById('targetTabHint');
|
||
if (hint && !elements.targetTab.value) {
|
||
hint.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
let onboardingStep = 0;
|
||
let onboardingTimeout = null;
|
||
|
||
function showOnboarding() {
|
||
const overlay = document.getElementById('onboarding-overlay');
|
||
if (!overlay) return;
|
||
document.body.style.minHeight = '420px';
|
||
overlay.style.display = 'block';
|
||
onboardingStep = 0;
|
||
renderOnboardingStep();
|
||
}
|
||
|
||
function positionSpotlightAndCard(targetEl) {
|
||
const spotlight = document.getElementById('onboarding-spotlight');
|
||
const card = document.getElementById('onboarding-card');
|
||
const arrow = document.getElementById('onboarding-arrow');
|
||
if (!spotlight || !card || !arrow) return;
|
||
|
||
spotlight.style.display = 'block';
|
||
card.style.display = 'block';
|
||
|
||
// Force a reflow
|
||
card.getBoundingClientRect();
|
||
spotlight.getBoundingClientRect();
|
||
|
||
const targetRect = targetEl.getBoundingClientRect();
|
||
const pad = 6;
|
||
const sLeft = targetRect.left - pad;
|
||
const sTop = targetRect.top - pad;
|
||
const sWidth = targetRect.width + (pad * 2);
|
||
const sHeight = targetRect.height + (pad * 2);
|
||
|
||
spotlight.style.left = `${sLeft}px`;
|
||
spotlight.style.top = `${sTop}px`;
|
||
spotlight.style.width = `${sWidth}px`;
|
||
spotlight.style.height = `${sHeight}px`;
|
||
spotlight.style.opacity = '1';
|
||
|
||
const cardWidth = 280;
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||
|
||
let cLeft = targetRect.left + (targetRect.width - cardWidth) / 2;
|
||
cLeft = Math.max(12, Math.min(320 - cardWidth - 12, cLeft));
|
||
|
||
const cardHeight = card.offsetHeight || 150;
|
||
let cTop = targetRect.bottom + 14;
|
||
let arrowDir = 'up';
|
||
|
||
if (cTop + cardHeight > viewportHeight - 12) {
|
||
cTop = targetRect.top - cardHeight - 14;
|
||
arrowDir = 'down';
|
||
}
|
||
|
||
if (cTop < 12) {
|
||
cTop = targetRect.bottom + 14;
|
||
arrowDir = 'up';
|
||
}
|
||
|
||
card.style.left = `${cLeft}px`;
|
||
card.style.top = `${cTop}px`;
|
||
card.style.opacity = '1';
|
||
card.style.transform = 'scale(1)';
|
||
|
||
arrow.style.left = '';
|
||
arrow.style.right = '';
|
||
arrow.style.top = '';
|
||
arrow.style.bottom = '';
|
||
arrow.style.borderColor = 'transparent';
|
||
|
||
const targetCenter = targetRect.left + (targetRect.width / 2);
|
||
const arrowLeft = targetCenter - cLeft - 6;
|
||
arrow.style.left = `${Math.max(12, Math.min(cardWidth - 24, arrowLeft))}px`;
|
||
|
||
if (arrowDir === 'up') {
|
||
arrow.style.top = '-12px';
|
||
arrow.style.borderBottomColor = 'var(--card)';
|
||
} else {
|
||
arrow.style.bottom = '-12px';
|
||
arrow.style.borderTopColor = 'var(--card)';
|
||
}
|
||
}
|
||
|
||
function centerCardFallback() {
|
||
const spotlight = document.getElementById('onboarding-spotlight');
|
||
const card = document.getElementById('onboarding-card');
|
||
const arrow = document.getElementById('onboarding-arrow');
|
||
if (!card) return;
|
||
|
||
if (spotlight) {
|
||
spotlight.style.display = 'none';
|
||
spotlight.style.opacity = '0';
|
||
}
|
||
if (arrow) {
|
||
arrow.style.borderColor = 'transparent';
|
||
}
|
||
|
||
card.style.display = 'block';
|
||
|
||
const cardWidth = 280;
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||
|
||
const cLeft = (320 - cardWidth) / 2;
|
||
const cTop = (viewportHeight - (card.offsetHeight || 150)) / 2;
|
||
|
||
card.style.left = `${cLeft}px`;
|
||
card.style.top = `${cTop}px`;
|
||
card.style.opacity = '1';
|
||
card.style.transform = 'scale(1)';
|
||
}
|
||
|
||
function renderOnboardingStep() {
|
||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||
|
||
const step = onboardingSteps[onboardingStep];
|
||
const icon = document.getElementById('onboarding-icon');
|
||
const title = document.getElementById('onboarding-title');
|
||
const text = document.getElementById('onboarding-text');
|
||
const nextBtn = document.getElementById('onboarding-next');
|
||
const stepIndicator = document.getElementById('onboarding-step-indicator');
|
||
const progressBar = document.getElementById('onboarding-progress-bar');
|
||
|
||
if (!icon || !title || !text || !nextBtn) return;
|
||
|
||
icon.textContent = step.icon;
|
||
title.textContent = step.title;
|
||
text.textContent = step.text;
|
||
|
||
if (stepIndicator) {
|
||
stepIndicator.textContent = `Step ${onboardingStep + 1} of ${onboardingSteps.length}`;
|
||
}
|
||
if (progressBar) {
|
||
progressBar.style.width = `${((onboardingStep + 1) / onboardingSteps.length) * 100}%`;
|
||
}
|
||
|
||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1
|
||
? (getMessage('ONBOARDING_DONE') !== 'ONBOARDING_DONE' ? getMessage('ONBOARDING_DONE') : 'Done!')
|
||
: getMessage('BTN_ONBOARDING_NEXT');
|
||
|
||
if (step.targetTab) {
|
||
const tabBtn = document.querySelector(`.tab-btn[data-tab="${step.targetTab}"]`);
|
||
if (tabBtn) tabBtn.click();
|
||
|
||
const syncActive = document.getElementById('sync-active');
|
||
const syncInactive = document.getElementById('sync-inactive');
|
||
if (step.targetTab === 'tab-sync') {
|
||
if (syncActive) syncActive.style.display = 'block';
|
||
if (syncInactive) syncInactive.style.display = 'none';
|
||
} else {
|
||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||
if (syncActive) syncActive.style.display = inRoom ? 'block' : 'none';
|
||
if (syncInactive) syncInactive.style.display = inRoom ? 'none' : 'block';
|
||
}
|
||
}
|
||
|
||
const spotlight = document.getElementById('onboarding-spotlight');
|
||
const card = document.getElementById('onboarding-card');
|
||
if (spotlight) spotlight.style.opacity = '0';
|
||
if (card) {
|
||
card.style.opacity = '0';
|
||
card.style.transform = 'scale(0.95)';
|
||
}
|
||
|
||
onboardingTimeout = setTimeout(() => {
|
||
const targetEl = step.targetSelector ? document.querySelector(step.targetSelector) : null;
|
||
if (targetEl && targetEl.offsetParent !== null) {
|
||
positionSpotlightAndCard(targetEl);
|
||
} else {
|
||
centerCardFallback();
|
||
}
|
||
}, 180);
|
||
}
|
||
|
||
function completeOnboarding() {
|
||
if (onboardingTimeout) clearTimeout(onboardingTimeout);
|
||
|
||
const overlay = document.getElementById('onboarding-overlay');
|
||
const spotlight = document.getElementById('onboarding-spotlight');
|
||
const card = document.getElementById('onboarding-card');
|
||
|
||
if (card) {
|
||
card.style.opacity = '0';
|
||
card.style.transform = 'scale(0.9)';
|
||
}
|
||
if (spotlight) {
|
||
spotlight.style.opacity = '0';
|
||
}
|
||
|
||
setTimeout(() => {
|
||
if (overlay) overlay.style.display = 'none';
|
||
document.body.style.minHeight = '';
|
||
chrome.storage.sync.set({ onboardingComplete: true });
|
||
|
||
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
|
||
toggleUIState(inRoom);
|
||
}, 300);
|
||
}
|
||
|
||
document.getElementById('onboarding-next')?.addEventListener('click', () => {
|
||
onboardingStep++;
|
||
if (onboardingStep >= onboardingSteps.length) {
|
||
completeOnboarding();
|
||
} else {
|
||
renderOnboardingStep();
|
||
}
|
||
});
|
||
|
||
document.getElementById('onboarding-skip')?.addEventListener('click', completeOnboarding);
|
||
|
||
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
|
||
if (e.target.id === 'onboarding-overlay') completeOnboarding();
|
||
});
|
||
|
||
elements.restartTourBtn?.addEventListener('click', () => {
|
||
onboardingStep = 0;
|
||
showOnboarding();
|
||
});
|