mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Merge pull request #15 from Shik3i/codex/issue-13-title-privacy
feat(extension): add title privacy controls and settings
This commit is contained in:
@@ -4,6 +4,13 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **Extension: Privacy title controls** - Advanced users can now disable sending browser tab titles separately from media titles. Media titles can still be sent in full, reduced to detected episode identifiers such as `S01E04`, or hidden entirely. Defaults remain full titles for backwards compatibility.
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.0] — 2026-06-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -156,7 +156,7 @@ While in a room, two heartbeats keep the session alive:
|
||||
| Heartbeat | Interval | Source | Purpose |
|
||||
|:----------|:---------|:-------|:--------|
|
||||
| **Background** | 30 seconds | `background.js` | While connected, signals "I'm still connected" and triggers automatic reconnect (500ms base, max 5s). No heartbeats fire when idle (lazy connect). |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, privacy-filtered `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
|
||||
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
|
||||
- **Room Cleanup**: Empty rooms are deleted immediately. Inactive rooms are pruned after 2 hours.
|
||||
@@ -184,7 +184,7 @@ When a user clicks **"Leave"** or closes their browser:
|
||||
When watching a series and an episode ends:
|
||||
|
||||
1. `content.js` monitors the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) for title changes.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected new title.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected title after applying the local media title privacy setting. In episode-only mode this is an identifier such as `S01E04`; when media titles are not sent, the client does not create a new episode lobby.
|
||||
3. All peers' videos freeze. The UI shows an "Episode Lobby" card with peer readiness status.
|
||||
4. Each peer's `content.js` polls for the new title to appear in the `<video>` element's metadata.
|
||||
5. Once a peer detects the matching title, they send `EPISODE_READY`.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ KoalaSync is designed with a **Security-First & Volatile** architecture. This me
|
||||
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId` and `username`. By default, KoalaSync also shares the selected tab title and media title with the room so peers can identify matching videos and coordinate episode transitions. Privacy Settings let you disable sending the tab title separately, and choose whether media titles are sent in full, reduced to a detected episode identifier (for example `S01E04`), or not sent. Playback metadata (`playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
|
||||
+109
-26
@@ -2,6 +2,7 @@ import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
@@ -362,21 +363,49 @@ async function getSettings() {
|
||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||
// has empty local storage but sync survives in the user's Google account).
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username']);
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
await chrome.storage.local.set({ username });
|
||||
}
|
||||
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
|
||||
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
||||
return {
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username
|
||||
username,
|
||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||
mediaTitlePrivacyMode
|
||||
};
|
||||
}
|
||||
|
||||
function getSharedTitleFields(settings, mediaTitle = null) {
|
||||
return {
|
||||
tabTitle: sanitizeTabTitle(currentTabTitle, settings?.sendTabTitle),
|
||||
mediaTitle: sanitizeSharedTitle(mediaTitle, settings?.mediaTitlePrivacyMode)
|
||||
};
|
||||
}
|
||||
|
||||
function withTitlePrivacy(payload, settings, keys) {
|
||||
return applyTitlePrivacyToPayload(payload, settings?.mediaTitlePrivacyMode, keys);
|
||||
}
|
||||
|
||||
function emitEpisodeLobbyForCurrentPrivacy() {
|
||||
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
||||
getSettings().then(settings => {
|
||||
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
||||
const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
||||
if (expectedTitle) {
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
||||
}
|
||||
}).catch(err => {
|
||||
addLog('Episode lobby privacy error: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Privacy + correctness: only onboardingComplete and dismissedHints belong in
|
||||
// storage.sync. Everything else is per-device local storage. This actively
|
||||
// removes legacy keys that older versions wrote to sync (and that would
|
||||
@@ -384,7 +413,8 @@ async function getSettings() {
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings'
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||
];
|
||||
function purgeLegacySyncKeys() {
|
||||
chrome.storage.sync.remove(LEGACY_SYNC_KEYS).catch(() => {});
|
||||
@@ -640,12 +670,13 @@ async function connect() {
|
||||
addLog('Joined Namespace /', 'success');
|
||||
const settings = await getSettings();
|
||||
if (settings.roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
password: settings.password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -1240,7 +1271,7 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
|
||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: episodeLobby.expectedTitle });
|
||||
emitEpisodeLobbyForCurrentPrivacy();
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'left') {
|
||||
@@ -1615,11 +1646,12 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) {
|
||||
const settings = await getSettings();
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
desynced: hcmDesynced
|
||||
});
|
||||
}
|
||||
@@ -1712,12 +1744,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
if (settings.roomId) connect();
|
||||
} else if (settings.roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
password: settings.password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -1929,12 +1962,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else if (roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -1964,7 +1998,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
const processEvent = () => {
|
||||
const processEvent = async () => {
|
||||
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
||||
// not drive the room. Don't broadcast; hand the action back to content.js so
|
||||
// it can snap the local player back / offer desync.
|
||||
@@ -2047,7 +2081,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
const settings = await getSettings();
|
||||
const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']);
|
||||
emit(message.action, { ...outboundPayload, peerId });
|
||||
sendResponse({ status: 'ok' });
|
||||
};
|
||||
|
||||
@@ -2062,10 +2098,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||
chrome.storage.session.set({ currentTabTitle });
|
||||
updateBadgeStatus();
|
||||
processEvent();
|
||||
processEvent().catch(err => {
|
||||
addLog('Content event privacy error: ' + err.message, 'error');
|
||||
sendResponse({ status: 'error' });
|
||||
});
|
||||
} else {
|
||||
routeToContent(message.action, message.payload);
|
||||
processEvent();
|
||||
processEvent().catch(err => {
|
||||
addLog('Content event privacy error: ' + err.message, 'error');
|
||||
sendResponse({ status: 'error' });
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'FORCE_SYNC_ACK') {
|
||||
if (isForceSyncInitiator) {
|
||||
@@ -2122,16 +2164,24 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle, desynced: hcmDesynced };
|
||||
const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle);
|
||||
const statusPayload = {
|
||||
...message.payload,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
mediaTitle: sharedTitles.mediaTitle,
|
||||
desynced: hcmDesynced
|
||||
};
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
if (me && typeof me === 'object') {
|
||||
me.tabTitle = currentTabTitle;
|
||||
me.tabTitle = sharedTitles.tabTitle;
|
||||
me.username = settings.username;
|
||||
me.mediaTitle = message.payload?.mediaTitle;
|
||||
me.mediaTitle = sharedTitles.mediaTitle;
|
||||
me.playbackState = message.payload?.playbackState;
|
||||
me.currentTime = message.payload?.currentTime;
|
||||
me.volume = message.payload?.volume;
|
||||
@@ -2193,10 +2243,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await getSettings();
|
||||
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
||||
if (!lobbyTitle) {
|
||||
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
||||
sendResponse({ status: 'title_privacy_no_lobby' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check setting
|
||||
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
||||
if (epSettings.autoSyncNextEpisode === false) {
|
||||
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
}
|
||||
@@ -2208,7 +2266,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// guest just follows / snaps back. Use amController() for parity with the
|
||||
// CONTENT_EVENT gate and the server's controllers-based check.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
||||
addLog(`Episode change ("${newTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
addLog(`Episode change ("${lobbyTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
sendResponse({ status: 'host_only_guest_skip' });
|
||||
return;
|
||||
}
|
||||
@@ -2218,18 +2276,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Live peer check, so the moment someone joins the next transition syncs.
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
if (otherCount === 0) {
|
||||
addLog(`Episode change ("${newTitle}") — alone in room, playing through without a lobby.`, 'info');
|
||||
addLog(`Episode change ("${lobbyTitle}") — alone in room, playing through without a lobby.`, 'info');
|
||||
sendResponse({ status: 'solo_no_lobby' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: newTitle });
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: lobbyTitle });
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
sendResponse({ status: 'ready_sent' });
|
||||
@@ -2241,26 +2299,26 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
// Create new lobby
|
||||
episodeLobby = {
|
||||
expectedTitle: newTitle,
|
||||
expectedTitle: lobbyTitle,
|
||||
initiatorPeerId: peerId,
|
||||
readyPeers: [peerId], // We are already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Episode lobby created: "${newTitle}"`, 'info');
|
||||
addLog(`Episode lobby created: "${lobbyTitle}"`, 'info');
|
||||
|
||||
// Tell content script to pause the video and start polling
|
||||
// (This is the only place we pause — after confirming the feature is enabled)
|
||||
if (sender.tab && sender.tab.id) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
type: 'PAUSE_FOR_LOBBY',
|
||||
expectedTitle: newTitle
|
||||
expectedTitle: lobbyTitle
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Broadcast to room
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: newTitle });
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle });
|
||||
|
||||
// Start timeout (Q1: Option B — cancel on timeout)
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
|
||||
@@ -2273,15 +2331,40 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Content script confirmed it loaded the lobby episode
|
||||
if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
const settings = await getSettings();
|
||||
const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode);
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: message.payload.title });
|
||||
addLog(`Local episode ready: "${message.payload.title}"`, 'success');
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle });
|
||||
addLog(`Local episode ready: "${readyTitle || episodeLobby.expectedTitle}"`, 'success');
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
||||
const settings = await getSettings();
|
||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
||||
const nextLobbyTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
||||
if (!nextLobbyTitle || nextLobbyTitle !== episodeLobby.expectedTitle) {
|
||||
cancelEpisodeLobby('Title privacy changed');
|
||||
}
|
||||
}
|
||||
if (currentRoom) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
mediaTitle: sharedTitles.mediaTitle,
|
||||
desynced: hcmDesynced
|
||||
});
|
||||
}
|
||||
if (currentRoom && currentTabId) {
|
||||
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CONTENT_BOOT') {
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Demnächst",
|
||||
"BTN_RESTART_TOUR": "Einführung neu starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Startet die Einführung für neue Benutzer erneut",
|
||||
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!"
|
||||
"HINT_SELECT_VIDEO": "Wähle hier dein Video aus!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Voll senden",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Nur Episoden-ID senden",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Nicht senden",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privatsphäre-Einstellungen",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Lege fest, welche Tab- und Medientitel an den Raum gesendet werden.",
|
||||
"LABEL_SEND_TAB_TITLE": "Tab-Titel senden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Sendet den Titel des ausgewählten Browser-Tabs an den Raum.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medientitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Coming soon",
|
||||
"BTN_RESTART_TOUR": "Restart Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Restart the onboarding tutorial",
|
||||
"HINT_SELECT_VIDEO": "Select your video here!"
|
||||
"HINT_SELECT_VIDEO": "Select your video here!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Send full title",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Send episode ID",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Do not send",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privacy Settings",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Choose which tab and media titles are sent to the room.",
|
||||
"LABEL_SEND_TAB_TITLE": "Send tab title",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Send the selected browser tab title to the room.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Media title",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Próximamente",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar el tutorial de inicio",
|
||||
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!"
|
||||
"HINT_SELECT_VIDEO": "¡Selecciona tu video aquí!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID de episodio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "No enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Ajustes de privacidad",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Elige qué títulos de pestaña y multimedia se envían a la sala.",
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título de pestaña",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envía a la sala el título de la pestaña del navegador seleccionada.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimedia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Bientôt disponible",
|
||||
"BTN_RESTART_TOUR": "Redémarrer le tutoriel",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Recommencer le tutoriel d'intégration",
|
||||
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !"
|
||||
"HINT_SELECT_VIDEO": "Sélectionnez votre vidéo ici !",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Envoyer le titre complet",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Envoyer l’identifiant d’épisode",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Ne pas envoyer",
|
||||
"LABEL_PRIVACY_SETTINGS": "Paramètres de confidentialité",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Choisissez quels titres d’onglet et de média sont envoyés au salon.",
|
||||
"LABEL_SEND_TAB_TITLE": "Envoyer le titre de l’onglet",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envoie au salon le titre de l’onglet de navigateur sélectionné.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titre du média",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Prossimamente",
|
||||
"BTN_RESTART_TOUR": "Riavvia Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Riavvia il tutorial introduttivo",
|
||||
"HINT_SELECT_VIDEO": "Scegli il tuo video qui!"
|
||||
"HINT_SELECT_VIDEO": "Scegli il tuo video qui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Invia titolo completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Invia ID episodio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Non inviare",
|
||||
"LABEL_PRIVACY_SETTINGS": "Impostazioni privacy",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Scegli quali titoli di scheda e contenuto multimediale vengono inviati alla stanza.",
|
||||
"LABEL_SEND_TAB_TITLE": "Invia titolo scheda",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Invia alla stanza il titolo della scheda del browser selezionata.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titolo multimediale",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "近日公開",
|
||||
"BTN_RESTART_TOUR": "チュートリアルを再起動",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "オンボーディングチュートリアルを再起動する",
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!"
|
||||
"HINT_SELECT_VIDEO": "ここで動画を選択してください!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "完全なタイトルを送信",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "エピソードIDを送信",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "送信しない",
|
||||
"LABEL_PRIVACY_SETTINGS": "プライバシー設定",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "ルームに送信するタブタイトルとメディアタイトルを選択します。",
|
||||
"LABEL_SEND_TAB_TITLE": "タブタイトルを送信",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "選択中のブラウザータブのタイトルをルームに送信します。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "メディアタイトル",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。"
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "출시 예정",
|
||||
"BTN_RESTART_TOUR": "튜토리얼 다시 시작",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "온보딩 튜토리얼을 다시 시작합니다",
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!"
|
||||
"HINT_SELECT_VIDEO": "여기에서 비디오를 선택하세요!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "전체 제목 보내기",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "에피소드 ID 보내기",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "보내지 않음",
|
||||
"LABEL_PRIVACY_SETTINGS": "개인정보 설정",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "방에 보낼 탭 제목과 미디어 제목을 선택합니다.",
|
||||
"LABEL_SEND_TAB_TITLE": "탭 제목 보내기",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "선택한 브라우저 탭 제목을 방에 보냅니다.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "미디어 제목",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Binnenkort beschikbaar",
|
||||
"BTN_RESTART_TOUR": "Tutorial opnieuw starten",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Start de introductie-tutorial opnieuw",
|
||||
"HINT_SELECT_VIDEO": "Selecteer hier je video!"
|
||||
"HINT_SELECT_VIDEO": "Selecteer hier je video!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Volledige titel verzenden",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Afleverings-ID verzenden",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Niet verzenden",
|
||||
"LABEL_PRIVACY_SETTINGS": "Privacy-instellingen",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Kies welke tab- en mediatitels naar de kamer worden gestuurd.",
|
||||
"LABEL_SEND_TAB_TITLE": "Tabtitel verzenden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Stuurt de titel van het geselecteerde browsertabblad naar de kamer.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Mediatitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Wkrótce",
|
||||
"BTN_RESTART_TOUR": "Uruchom samouczek ponownie",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Uruchom ponownie samouczek powitalny",
|
||||
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!"
|
||||
"HINT_SELECT_VIDEO": "Wybierz swoje wideo tutaj!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Wysyłaj pełny tytuł",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Wysyłaj ID odcinka",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Nie wysyłaj",
|
||||
"LABEL_PRIVACY_SETTINGS": "Ustawienia prywatności",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Wybierz, które tytuły kart i multimediów są wysyłane do pokoju.",
|
||||
"LABEL_SEND_TAB_TITLE": "Wysyłaj tytuł karty",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Wysyła do pokoju tytuł wybranej karty przeglądarki.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Tytuł multimediów",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial de introdução",
|
||||
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!"
|
||||
"HINT_SELECT_VIDEO": "Selecione seu vídeo aqui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID do episódio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Não enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Configurações de privacidade",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Escolha quais títulos de aba e mídia são enviados para a sala.",
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título da aba",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título da aba selecionada do navegador.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título da mídia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Em breve",
|
||||
"BTN_RESTART_TOUR": "Reiniciar Tutorial",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Reiniciar o tutorial introdutório",
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!"
|
||||
"HINT_SELECT_VIDEO": "Selecione o seu vídeo aqui!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Enviar título completo",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Enviar ID do episódio",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Não enviar",
|
||||
"LABEL_PRIVACY_SETTINGS": "Definições de privacidade",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Escolhe que títulos do separador e do conteúdo multimédia são enviados para a sala.",
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título do separador",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título do separador do navegador selecionado.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimédia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Скоро",
|
||||
"BTN_RESTART_TOUR": "Перезапустить обучение",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Запустить приветственное руководство заново",
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!"
|
||||
"HINT_SELECT_VIDEO": "Выберите ваше видео здесь!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Отправлять полное название",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Отправлять ID эпизода",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Не отправлять",
|
||||
"LABEL_PRIVACY_SETTINGS": "Настройки приватности",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Выберите, какие названия вкладки и медиа отправляются в комнату.",
|
||||
"LABEL_SEND_TAB_TITLE": "Отправлять название вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Отправляет в комнату название выбранной вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Название медиа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Çok yakında",
|
||||
"BTN_RESTART_TOUR": "Eğitimi Yeniden Başlat",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Tanıtım eğitimini yeniden başlat",
|
||||
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!"
|
||||
"HINT_SELECT_VIDEO": "Videonuzu buradan seçin!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Tam başlığı gönder",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Bölüm ID’sini gönder",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Gönderme",
|
||||
"LABEL_PRIVACY_SETTINGS": "Gizlilik ayarları",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Odaya hangi sekme ve medya başlıklarının gönderileceğini seçin.",
|
||||
"LABEL_SEND_TAB_TITLE": "Sekme başlığını gönder",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Seçili tarayıcı sekmesinin başlığını odaya gönderir.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medya başlığı",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "Скоро буде",
|
||||
"BTN_RESTART_TOUR": "Перезапустіть підручник",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "Перезапустіть навчальний посібник із адаптації",
|
||||
"HINT_SELECT_VIDEO": "Виберіть своє відео тут!"
|
||||
"HINT_SELECT_VIDEO": "Виберіть своє відео тут!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "Надсилати повну назву",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "Надсилати ID епізоду",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "Не надсилати",
|
||||
"LABEL_PRIVACY_SETTINGS": "Налаштування приватності",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "Виберіть, які назви вкладки й медіа надсилати до кімнати.",
|
||||
"LABEL_SEND_TAB_TITLE": "Надсилати назву вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Надсилає до кімнати назву вибраної вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Назва медіа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати."
|
||||
}
|
||||
|
||||
@@ -229,5 +229,14 @@
|
||||
"AUDIO_COMING_SOON": "即将推出",
|
||||
"BTN_RESTART_TOUR": "重启教程",
|
||||
"BTN_RESTART_TOUR_TOOLTIP": "重新启动入门教程",
|
||||
"HINT_SELECT_VIDEO": "在这里选择您的视频!"
|
||||
"HINT_SELECT_VIDEO": "在这里选择您的视频!",
|
||||
"OPTION_TITLE_PRIVACY_FULL": "发送完整标题",
|
||||
"OPTION_TITLE_PRIVACY_EPISODE": "发送剧集 ID",
|
||||
"OPTION_TITLE_PRIVACY_HIDDEN": "不发送",
|
||||
"LABEL_PRIVACY_SETTINGS": "隐私设置",
|
||||
"LABEL_PRIVACY_SETTINGS_TOOLTIP": "选择要发送到房间的标签页标题和媒体标题。",
|
||||
"LABEL_SEND_TAB_TITLE": "发送标签页标题",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "将所选浏览器标签页的标题发送到房间。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "媒体标题",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。"
|
||||
}
|
||||
|
||||
@@ -550,6 +550,25 @@
|
||||
<input type="text" id="username" data-i18n-placeholder="PLACEHOLDER_USERNAME" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
|
||||
</div>
|
||||
|
||||
<details class="form-group" style="background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<summary style="cursor: pointer; font-size: 12px; font-weight: 700; color: var(--text);" title="Choose which titles are sent to the room." data-i18n="LABEL_PRIVACY_SETTINGS" data-i18n-title="LABEL_PRIVACY_SETTINGS_TOOLTIP">Privacy Settings</summary>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Send the selected browser tab title to the room." data-i18n="LABEL_SEND_TAB_TITLE" data-i18n-title="LABEL_SEND_TAB_TITLE_TOOLTIP">Send tab title</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="sendTabTitle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Choose how media titles are sent to the room." data-i18n="LABEL_MEDIA_TITLE_PRIVACY" data-i18n-title="LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP">Media title</label>
|
||||
<select id="mediaTitlePrivacyMode" style="width: 165px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer; outline: none; font-family: inherit;">
|
||||
<option value="full" data-i18n="OPTION_TITLE_PRIVACY_FULL">Send full title</option>
|
||||
<option value="episode" data-i18n="OPTION_TITLE_PRIVACY_EPISODE">Send episode ID</option>
|
||||
<option value="hidden" data-i18n="OPTION_TITLE_PRIVACY_HIDDEN">Do not send</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
|
||||
+24
-1
@@ -2,6 +2,7 @@ import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './
|
||||
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 } from './title-privacy.js';
|
||||
|
||||
|
||||
const elements = {
|
||||
@@ -54,6 +55,8 @@ const elements = {
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
@@ -184,7 +187,7 @@ function setRoomRefreshCooldown() {
|
||||
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', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -209,6 +212,10 @@ async function init() {
|
||||
elements.username.value = username;
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
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;
|
||||
@@ -1222,6 +1229,22 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { extractEpisodeId } from './episode-utils.js';
|
||||
|
||||
export const TITLE_PRIVACY_MODES = Object.freeze({
|
||||
FULL: 'full',
|
||||
EPISODE: 'episode',
|
||||
HIDDEN: 'hidden'
|
||||
});
|
||||
|
||||
export function normalizeTitlePrivacyMode(mode) {
|
||||
return Object.values(TITLE_PRIVACY_MODES).includes(mode)
|
||||
? mode
|
||||
: TITLE_PRIVACY_MODES.FULL;
|
||||
}
|
||||
|
||||
export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_MODES.FULL) {
|
||||
if (typeof sendTabTitle === 'boolean') return sendTabTitle;
|
||||
return normalizeTitlePrivacyMode(legacyMode) === TITLE_PRIVACY_MODES.FULL;
|
||||
}
|
||||
|
||||
export function sanitizeTabTitle(title, sendTabTitle) {
|
||||
if (!sendTabTitle) return null;
|
||||
return typeof title === 'string' && title.length > 0 ? title : null;
|
||||
}
|
||||
|
||||
export function sanitizeSharedTitle(title, mode) {
|
||||
const normalizedMode = normalizeTitlePrivacyMode(mode);
|
||||
if (normalizedMode === TITLE_PRIVACY_MODES.HIDDEN) return null;
|
||||
|
||||
if (typeof title !== 'string' || title.length === 0) return null;
|
||||
if (normalizedMode === TITLE_PRIVACY_MODES.EPISODE) {
|
||||
return extractEpisodeId(title) || null;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
export function applyTitlePrivacyToPayload(payload, mode, keys = ['mediaTitle', 'expectedTitle', 'title']) {
|
||||
const source = payload && typeof payload === 'object' ? payload : {};
|
||||
const next = { ...source };
|
||||
keys.forEach(key => {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
next[key] = sanitizeSharedTitle(next[key], mode);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
} from '../extension/title-privacy.js';
|
||||
|
||||
assert.equal(normalizeTitlePrivacyMode(undefined), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode('unknown'), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN), TITLE_PRIVACY_MODES.HIDDEN);
|
||||
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
|
||||
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
|
||||
assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
|
||||
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
|
||||
|
||||
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
|
||||
assert.equal(sanitizeTabTitle('Private Tab', false), null);
|
||||
assert.equal(sanitizeTabTitle('', true), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'full'), 'Example Movie');
|
||||
assert.equal(sanitizeSharedTitle('', 'full'), null);
|
||||
assert.equal(sanitizeSharedTitle(null, 'full'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode'), 'S01E04');
|
||||
assert.equal(sanitizeSharedTitle('Folge 7 - Private Server', 'episode'), 'EP007');
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'episode'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01E04', 'hidden'), null);
|
||||
assert.equal(sanitizeSharedTitle('Private Tab Title', 'hidden'), null);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'Show Name - S01E04',
|
||||
currentTime: 42
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'S01E04',
|
||||
currentTime: 42
|
||||
},
|
||||
'media privacy must not rewrite tabTitle'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
},
|
||||
'media privacy must not rewrite tabTitle or add absent media keys'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: 'Private Media',
|
||||
expectedTitle: 'S01E04',
|
||||
title: 'S01E04'
|
||||
}, 'hidden'),
|
||||
{
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: null,
|
||||
expectedTitle: null,
|
||||
title: null
|
||||
},
|
||||
'hidden media privacy must not clear tabTitle'
|
||||
);
|
||||
|
||||
console.log('title-privacy tests passed');
|
||||
@@ -13,6 +13,7 @@ const checks = [
|
||||
}],
|
||||
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
|
||||
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
|
||||
['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
|
||||
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
|
||||
['names generator', 'node', ['scripts/test-names.mjs']],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
|
||||
Reference in New Issue
Block a user