Files
KefinTweaks/scripts/multiUserSession.js
2026-09-10 07:25:06 -04:00

1481 lines
57 KiB
JavaScript

/**
* KefinTweaks Multi-User Sessions / Watch Together
* Account-scoped roster in CustomPrefs.kefinTweaks.watchTogether;
* device-local enabledUserIds + lastSelectionAt; sync primary playstate to enabled users.
*/
(function () {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks MultiUserSession]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks MultiUserSession]', ...args);
const ERR = (...args) => console.error('[KefinTweaks MultiUserSession]', ...args);
const DEVICE_STORAGE_PREFIX = 'kefinTweaks_watchTogether';
const FEATURE_KEY = 'watchTogether';
const MODAL_ID = 'kefin-multi-user-session-modal';
const ADD_USER_MODAL_ID = 'kefin-multi-user-session-add-modal';
const WATCH_TOGETHER_MODAL_ID = 'kefin-watch-together-prompt';
const REMOVE_CONFIRM_MODAL_ID = 'kefin-watch-together-remove-confirm';
const SYNC_DEBOUNCE_MS = 250;
const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
let syncStarted = false;
let activeModal = null;
let addUserModal = null;
let watchTogetherModal = null;
/** Refreshes the open Watch Together picker in place (avatars + pending selection). */
let refreshWatchTogetherPicker = null;
const syncDebounceTimers = new Map();
let syncMirrorDepth = 0;
/** Cached CustomPrefs roster: { users: [{ userId, name, accessToken }] } */
let groupCache = { users: [] };
let groupHydrated = false;
let inContinuousVideoSession = false;
let isAddingOsdButton = false;
function getUi() {
return window.KefinTweaksUI || {};
}
function injectStyles() {
if (document.getElementById('kefin-multi-user-session-styles')) return;
const style = document.createElement('style');
style.id = 'kefin-multi-user-session-styles';
style.textContent = `
.headerUserButton[data-multi-user] .material-icons.person::before,
[data-multi-user].material-icons.person::before {
content: "\\e7ef";
}
.mus-watch-together-avatars {
display: flex;
flex-wrap: wrap;
gap: 1em;
justify-content: center;
padding-top: 1em;
}
.mus-watch-together-avatar-wrap {
position: relative;
}
.mus-watch-together-avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4em;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: inherit;
}
.mus-watch-together-avatar .mus-avatar-circle {
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
border: 3px solid transparent;
box-sizing: border-box;
background: rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
opacity: 0.45;
}
.mus-watch-together-avatar.is-enabled .mus-avatar-circle {
border-color: rgba(0, 164, 220, 0.95);
box-shadow: 0 0 0 2px rgba(0, 164, 220, 0.35);
opacity: 1;
}
.mus-watch-together-avatar .mus-avatar-circle img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mus-watch-together-avatar .mus-avatar-fallback {
font-size: 28px;
opacity: 0.85;
}
.mus-watch-together-avatar .mus-avatar-name {
font-size: 0.75em;
text-align: center;
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mus-avatar-remove {
position: absolute;
top: -8px;
right: -8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.75);
color: #fff;
z-index: 2;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.mus-watch-together-avatar-wrap:hover .mus-avatar-remove,
.mus-watch-together-avatar-wrap:focus-within .mus-avatar-remove {
opacity: 1;
pointer-events: auto;
}
.mus-avatar-remove .material-icons {
font-size: 14px;
line-height: 1;
}
.mus-wt-footer-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5em;
width: 100%;
justify-content: flex-end;
align-items: center;
}
.mus-wt-footer-actions .emby-button {
margin: 0;
}
.material-icons.group_off::before {
content: "\\e747";
}
#videoOsdPage:not(.hide) button.btnWatchTogether {
margin: 0;
}
`;
document.head.appendChild(style);
}
function escapeHtml(str) {
if (typeof getUi().escapeHtml === 'function') return getUi().escapeHtml(str);
return String(str || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getPrimaryUserId() {
return (typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId)
? ApiClient.getCurrentUserId()
: null;
}
function getPrimaryUserName() {
if (typeof ApiClient !== 'undefined') {
if (ApiClient._currentUser?.Name) return ApiClient._currentUser.Name;
const id = getPrimaryUserId();
if (id) return id;
}
return 'User';
}
function getDeviceStorageKey() {
const serverId = (typeof ApiClient !== 'undefined' && ApiClient.serverId)
? (ApiClient.serverId() || '')
: '';
const primaryUserId = getPrimaryUserId() || '';
if (!serverId || !primaryUserId) return null;
return `${DEVICE_STORAGE_PREFIX}:${serverId}:${primaryUserId}`;
}
function readDeviceState() {
const key = getDeviceStorageKey();
if (!key) return { enabledUserIds: [], lastSelectionAt: 0 };
try {
const raw = localStorage.getItem(key);
if (!raw) return { enabledUserIds: [], lastSelectionAt: 0 };
const parsed = JSON.parse(raw);
const enabledUserIds = Array.isArray(parsed?.enabledUserIds)
? parsed.enabledUserIds.filter(Boolean)
: [];
const lastSelectionAt = Number(parsed?.lastSelectionAt) || 0;
return { enabledUserIds, lastSelectionAt };
} catch (e) {
WARN('Failed to read device watch-together state:', e);
return { enabledUserIds: [], lastSelectionAt: 0 };
}
}
function writeDeviceState(state) {
const key = getDeviceStorageKey();
if (!key) return false;
try {
const enabledUserIds = Array.isArray(state?.enabledUserIds)
? [...new Set(state.enabledUserIds.filter(Boolean))]
: [];
const lastSelectionAt = Number(state?.lastSelectionAt) || 0;
localStorage.setItem(key, JSON.stringify({ enabledUserIds, lastSelectionAt }));
updateHeaderMultiUserIndicator();
updateSyncListenerState();
try {
refreshPreferencesMenuLinks();
} catch (uiErr) {
WARN('Failed to refresh preferences menu links:', uiErr);
}
return true;
} catch (e) {
ERR('Failed to write device watch-together state:', e);
return false;
}
}
function stampLastSelectionAt(enabledUserIds) {
const current = readDeviceState();
return writeDeviceState({
enabledUserIds: enabledUserIds != null ? enabledUserIds : current.enabledUserIds,
lastSelectionAt: Date.now()
});
}
function normalizeWatchTogether(raw) {
const users = Array.isArray(raw?.users) ? raw.users : [];
return {
users: users
.filter((u) => u && u.userId && (u.accessToken || u.viaAdmin === true))
.map((u) => {
const entry = {
userId: u.userId,
name: u.name || ''
};
if (u.viaAdmin === true) {
entry.viaAdmin = true;
} else if (u.accessToken) {
entry.accessToken = u.accessToken;
}
return entry;
})
};
}
async function hydrateGroupCache() {
try {
const raw = await window.userHelper?.getKefinTweaksFeature?.(FEATURE_KEY, { users: [] });
groupCache = normalizeWatchTogether(raw);
groupHydrated = true;
return groupCache;
} catch (e) {
WARN('Failed to hydrate watchTogether group:', e);
groupCache = { users: [] };
groupHydrated = true;
return groupCache;
}
}
async function persistGroup(nextGroup) {
const normalized = normalizeWatchTogether(nextGroup);
const ok = await window.userHelper?.setKefinTweaksFeature?.(FEATURE_KEY, normalized);
if (ok) {
groupCache = normalized;
groupHydrated = true;
updateHeaderMultiUserIndicator();
updateSyncListenerState();
try {
refreshPreferencesMenuLinks();
} catch (uiErr) {
WARN('Failed to refresh preferences menu links:', uiErr);
}
}
return ok;
}
function getSessionUsers() {
return (groupCache.users || []).slice();
}
function getEnabledSessionUsers() {
const primaryId = getPrimaryUserId();
const { enabledUserIds } = readDeviceState();
const enabledSet = new Set(enabledUserIds);
return getSessionUsers().filter(
(u) => enabledSet.has(u.userId)
&& u.userId
&& u.userId !== primaryId
&& (u.accessToken || u.viaAdmin === true)
);
}
async function upsertSessionUser(entry) {
const users = getSessionUsers();
const idx = users.findIndex((u) => u.userId === entry.userId);
const next = {
userId: entry.userId,
name: entry.name || ''
};
if (entry.viaAdmin === true) {
next.viaAdmin = true;
} else if (entry.accessToken) {
next.accessToken = entry.accessToken;
} else {
ERR('upsertSessionUser requires accessToken or viaAdmin');
return false;
}
if (idx >= 0) users[idx] = next;
else users.push(next);
const ok = await persistGroup({ users });
if (ok) {
const device = readDeviceState();
if (!device.enabledUserIds.includes(entry.userId)) {
writeDeviceState({
enabledUserIds: [...device.enabledUserIds, entry.userId],
lastSelectionAt: device.lastSelectionAt
});
}
}
return ok;
}
function setUserEnabled(userId, enabled) {
const device = readDeviceState();
const set = new Set(device.enabledUserIds);
if (enabled) set.add(userId);
else set.delete(userId);
return writeDeviceState({
enabledUserIds: [...set],
lastSelectionAt: device.lastSelectionAt
});
}
async function removeSessionUser(userId) {
const users = getSessionUsers().filter((u) => u.userId !== userId);
const ok = await persistGroup({ users });
const device = readDeviceState();
writeDeviceState({
enabledUserIds: device.enabledUserIds.filter((id) => id !== userId),
lastSelectionAt: device.lastSelectionAt
});
return ok;
}
function getSessionTimeoutMs() {
const minutes = window.KefinTweaksConfig?.multiUserSession?.sessionTimeout;
const n = Number(minutes);
const use = Number.isFinite(n) && n > 0 ? n : DEFAULT_SESSION_TIMEOUT_MINUTES;
return use * 60 * 1000;
}
function getHeaderUserButtonTitle() {
const enabledUsers = getEnabledSessionUsers();
if (!enabledUsers.length) return null;
const others = enabledUsers.map((u) => u.name || u.userId).join(', ');
return `${getPrimaryUserName()} and ${others}`;
}
function updateHeaderMultiUserIndicator() {
const hasMulti = getEnabledSessionUsers().length > 0;
const multiTitle = hasMulti ? getHeaderUserButtonTitle() : null;
document.querySelectorAll('.headerUserButton').forEach((btn) => {
if (hasMulti) {
if (!btn.hasAttribute('data-mus-original-title')) {
const existing = btn.getAttribute('title');
if (existing) btn.setAttribute('data-mus-original-title', existing);
}
if (multiTitle) btn.setAttribute('title', multiTitle);
btn.setAttribute('data-multi-user', 'true');
btn.querySelectorAll('.material-icons.person').forEach((icon) => {
icon.setAttribute('data-multi-user', 'true');
});
} else {
if (btn.hasAttribute('data-mus-original-title')) {
const original = btn.getAttribute('data-mus-original-title');
if (original) btn.setAttribute('title', original);
else btn.removeAttribute('title');
btn.removeAttribute('data-mus-original-title');
}
btn.removeAttribute('data-multi-user');
btn.querySelectorAll('[data-multi-user]').forEach((el) => {
el.removeAttribute('data-multi-user');
});
}
});
}
function authHeaderForToken(token) {
if (window.apiHelper?.getAuthHeaderForToken) {
return window.apiHelper.getAuthHeaderForToken(token);
}
return '';
}
/** Primary admin token for viaAdmin members; stored token for password-linked members. */
function authHeaderForSessionUser(user) {
if (user?.viaAdmin === true || !user?.accessToken) {
if (window.apiHelper?.getAuthHeader) {
return window.apiHelper.getAuthHeader();
}
return authHeaderForToken(
typeof ApiClient !== 'undefined' && ApiClient.accessToken
? ApiClient.accessToken()
: ''
);
}
return authHeaderForToken(user.accessToken);
}
async function authenticateByName(username, password) {
const serverUrl = ApiClient.serverAddress();
if (!serverUrl) throw new Error('Server address unavailable');
const response = await fetch(`${serverUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
Authorization: authHeaderForToken(''),
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
Username: username,
Pw: password
})
});
if (!response.ok) {
let detail = `${response.status}`;
try {
const errBody = await response.json();
if (errBody?.message || errBody?.Message) {
detail = errBody.message || errBody.Message;
}
} catch (_) { /* ignore */ }
throw new Error(detail);
}
return response.json();
}
async function logoutSessionToken(accessToken) {
if (!accessToken) return;
try {
const serverUrl = ApiClient.serverAddress();
if (!serverUrl) return;
await fetch(`${serverUrl}/Sessions/Logout`, {
method: 'POST',
headers: {
Authorization: authHeaderForToken(accessToken),
'Content-Type': 'application/json'
}
});
} catch (e) {
WARN('Best-effort logout failed:', e);
}
}
function getSessionUserIds() {
return new Set(getSessionUsers().map((u) => u.userId));
}
function isPrimaryUserDataEvent(msg) {
const eventUserId = msg?.Data?.UserId;
const primaryId = getPrimaryUserId();
if (!primaryId || !eventUserId) return false;
if (eventUserId !== primaryId) return false;
return !getSessionUserIds().has(eventUserId);
}
async function markItemPlayed(user, itemId) {
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/PlayedItems/${encodeURIComponent(itemId)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeaderForSessionUser(user),
'Content-Type': 'application/json'
}
});
if (response.status === 401 || response.status === 403) {
setUserEnabled(user.userId, false);
WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on PlayedItems`);
return false;
}
if (!response.ok) {
throw new Error(`PlayedItems ${response.status}`);
}
return true;
}
async function mirrorUserData(user, itemId, userData) {
const serverUrl = ApiClient.serverAddress();
const url = `${serverUrl}/Users/${encodeURIComponent(user.userId)}/Items/${encodeURIComponent(itemId)}/UserData`;
const body = {
PlaybackPositionTicks: userData.PlaybackPositionTicks ?? 0,
PlayedPercentage: userData.PlayedPercentage,
PlayCount: userData.PlayCount,
IsFavorite: userData.IsFavorite,
LastPlayedDate: userData.LastPlayedDate
};
if (userData.Played === true) {
body.Played = true;
}
Object.keys(body).forEach((k) => {
if (body[k] === undefined) delete body[k];
});
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeaderForSessionUser(user),
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (response.status === 401 || response.status === 403) {
setUserEnabled(user.userId, false);
WARN(`Disabled session user ${user.name || user.userId} after ${response.status} on UserData`);
return false;
}
if (!response.ok) {
throw new Error(`UserData ${response.status}`);
}
return true;
}
async function syncUserDataToSessionUsers(userData) {
if (!userData?.ItemId) return;
const itemId = userData.ItemId;
const targets = getEnabledSessionUsers();
if (!targets.length) return;
const hasProgress = typeof userData.PlaybackPositionTicks === 'number'
&& userData.PlaybackPositionTicks > 0;
const shouldPushUserData = hasProgress
|| userData.PlaybackPositionTicks === 0
|| userData.PlayedPercentage != null;
const shouldMarkPlayed = userData.Played === true;
if (!shouldMarkPlayed && !shouldPushUserData) return;
syncMirrorDepth += 1;
try {
await Promise.all(targets.map(async (user) => {
try {
if (shouldMarkPlayed) {
await markItemPlayed(user, itemId);
}
if (shouldPushUserData) {
await mirrorUserData(user, itemId, userData);
}
} catch (e) {
WARN(`Sync failed for ${user.name || user.userId}:`, e);
}
}));
} finally {
syncMirrorDepth = Math.max(0, syncMirrorDepth - 1);
}
}
function scheduleSyncUserData(userData) {
if (!userData?.ItemId) return;
const itemId = userData.ItemId;
clearTimeout(syncDebounceTimers.get(itemId));
syncDebounceTimers.set(itemId, setTimeout(() => {
syncDebounceTimers.delete(itemId);
syncUserDataToSessionUsers(userData).catch((e) => WARN('Debounced sync error:', e));
}, SYNC_DEBOUNCE_MS));
}
function handleUserDataChangedMessage(msg) {
if (syncMirrorDepth > 0) return;
if (!isPrimaryUserDataEvent(msg)) return;
const list = msg?.Data?.UserDataList;
if (!Array.isArray(list) || !list.length) return;
if (!getEnabledSessionUsers().length) return;
list.forEach((entry) => scheduleSyncUserData(entry));
}
function stopSync() {
if (!syncStarted) return;
if (window.websocketHelper?.unlisten) {
window.websocketHelper.unlisten('UserDataChanged', handleUserDataChangedMessage);
}
syncStarted = false;
syncDebounceTimers.forEach((timer) => clearTimeout(timer));
syncDebounceTimers.clear();
LOG('Playstate sync stopped (no enabled session users)');
}
function startSync() {
if (syncStarted) return;
if (!getEnabledSessionUsers().length) return;
if (!window.websocketHelper?.listen) {
WARN('websocketHelper not available; retrying sync start');
setTimeout(updateSyncListenerState, 1000);
return;
}
window.websocketHelper.listen('UserDataChanged', handleUserDataChangedMessage);
syncStarted = true;
LOG('Playstate sync listening for UserDataChanged');
}
function updateSyncListenerState() {
if (getEnabledSessionUsers().length > 0) {
startSync();
} else {
stopSync();
}
}
function getUserAvatarUrl(userId) {
const server = ApiClient?.serverAddress?.() || '';
if (!server || !userId) return '';
return `${server}/Users/${encodeURIComponent(userId)}/Images/Primary`;
}
/**
* Shared Watch Together account picker UI (playback prompt + manage modal).
* @param {Array} users
* @param {string[]} enabledUserIds
* @param {{ showRemove?: boolean }} [options]
*/
function buildAvatarToggleHtml(users, enabledUserIds, options = {}) {
const { showRemove = false } = options;
const enabledSet = new Set(enabledUserIds);
if (!users.length) {
return `<div class="listItemBodyText secondary" style="text-align:center;padding:1em 0;">No accounts in your Watch Together group yet.</div>`;
}
const items = users.map((user) => {
const enabled = enabledSet.has(user.userId);
const name = escapeHtml(user.name || user.userId);
const src = escapeHtml(getUserAvatarUrl(user.userId));
const removeBtn = showRemove
? `<button type="button" class="mus-avatar-remove" data-user-id="${escapeHtml(user.userId)}" title="Remove ${name}" aria-label="Remove ${name}">
<span class="material-icons delete" aria-hidden="true"></span>
</button>`
: '';
return `
<div class="mus-watch-together-avatar-wrap">
${removeBtn}
<button type="button" class="mus-watch-together-avatar ${enabled ? 'is-enabled' : ''}"
data-user-id="${escapeHtml(user.userId)}" title="${name}" aria-pressed="${enabled}">
<span class="mus-avatar-circle">
<img src="${src}" alt="${name}" loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.style.display='inline-block';">
<span class="material-icons person mus-avatar-fallback" aria-hidden="true" style="display:none;"></span>
</span>
<span class="mus-avatar-name listItemBodyText">${name}</span>
</button>
</div>
`;
}).join('');
return `<div class="mus-watch-together-avatars">${items}</div>`;
}
function buildWatchTogetherPickerBodyHtml(users, enabledUserIds, options = {}) {
const { showRemove = false, hint = 'Tap accounts to include them in watch-state sync for this session.' } = options;
return `
<div class="mus-watch-together-prompt" style="padding: 0.25em 0;">
<div class="listItemBodyText secondary" style="margin-bottom: 1em;">
${escapeHtml(hint)}
</div>
<hr class="separator" aria-hidden="true">
${buildAvatarToggleHtml(users, enabledUserIds, { showRemove })}
</div>
`;
}
/**
* @param {HTMLElement} root
* @param {string[]} pendingEnabledIds - mutated in place for pending selection
* @param {{ applyImmediately?: boolean, onRemove?: Function }} [options]
*/
function bindAvatarToggleHandlers(root, pendingEnabledIds, options = {}) {
const { applyImmediately = false, onRemove = null } = options;
root.querySelectorAll('.mus-watch-together-avatar').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const userId = btn.getAttribute('data-user-id');
if (!userId) return;
const idx = pendingEnabledIds.indexOf(userId);
if (idx >= 0) pendingEnabledIds.splice(idx, 1);
else pendingEnabledIds.push(userId);
const enabled = pendingEnabledIds.includes(userId);
btn.classList.toggle('is-enabled', enabled);
btn.setAttribute('aria-pressed', String(enabled));
if (applyImmediately) {
setUserEnabled(userId, enabled);
stampLastSelectionAt([...pendingEnabledIds]);
updateWatchStateSyncListIcon(
document.querySelector('.userPreferencesPage:not(.hide) [data-kefin-watch-state-sync]')
);
}
});
});
root.querySelectorAll('.mus-avatar-remove').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const userId = btn.getAttribute('data-user-id');
if (!userId) return;
const user = getSessionUsers().find((u) => u.userId === userId);
const label = user?.name || userId;
const readdHint = user?.viaAdmin
? 'You can re-add them from the user list.'
: 'They will need to be authenticated again to re-add.';
const confirmed = await confirmRemoveWatchTogetherUser(label, readdHint);
if (!confirmed) return;
const token = user?.accessToken;
await removeSessionUser(userId);
if (token) await logoutSessionToken(token);
const idx = pendingEnabledIds.indexOf(userId);
if (idx >= 0) pendingEnabledIds.splice(idx, 1);
if (typeof onRemove === 'function') {
onRemove(userId);
} else {
btn.closest('.mus-watch-together-avatar-wrap')?.remove();
if (!root.querySelector('.mus-watch-together-avatar-wrap')) {
const host = root.querySelector('.mus-watch-together-avatars');
if (host) {
host.outerHTML = buildAvatarToggleHtml([], [], { showRemove: true });
}
}
}
});
});
}
/**
* Jellyfin-style confirm for removing a Watch Together member.
* @returns {Promise<boolean>}
*/
function confirmRemoveWatchTogetherUser(label, readdHint) {
return new Promise((resolve) => {
let settled = false;
const settle = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
if (!window.ModalSystem?.create) {
settle(window.confirm(`Remove ${label} from Watch Together? ${readdHint}`));
return;
}
if (window.ModalSystem.isOpen?.(REMOVE_CONFIRM_MODAL_ID)) {
window.ModalSystem.close(REMOVE_CONFIRM_MODAL_ID);
}
const modal = window.ModalSystem.create({
id: REMOVE_CONFIRM_MODAL_ID,
title: 'Remove User',
content: `
<div class="listItemBodyText">
Remove <strong>${escapeHtml(label)}</strong> from Watch Together?
</div>
<div class="listItemBodyText secondary" style="margin-top: 0.75em;">
${escapeHtml(readdHint)}
</div>
`,
footer: `
<div class="mus-wt-footer-actions" style="display:flex;flex-direction:row;flex-wrap:wrap;gap:0.5em;width:100%;justify-content:flex-end;align-items:center;">
<button type="button" class="raised emby-button" id="mus-remove-cancel">Cancel</button>
<button type="button" class="raised button-submit emby-button" id="mus-remove-confirm">Remove</button>
</div>
`,
// Avoid Escape also closing the parent Watch Together modal (both listen on document).
closeOnBackdrop: true,
closeOnEscape: false,
showCloseButton: true,
onOpen: (modalInstance) => {
if (modalInstance?.dialog) {
modalInstance.dialog.style.maxWidth = '420px';
modalInstance.dialog.style.width = '90vw';
}
modalInstance.dialogFooter?.querySelector('#mus-remove-cancel')?.addEventListener('click', (e) => {
e.preventDefault();
settle(false);
modalInstance.close();
});
modalInstance.dialogFooter?.querySelector('#mus-remove-confirm')?.addEventListener('click', (e) => {
e.preventDefault();
settle(true);
modalInstance.close();
});
}
});
if (modal) {
modal.onClose = () => settle(false);
}
});
}
function openWatchTogetherPrompt(options = {}) {
const {
onComplete,
title = 'Watch Together',
showRemove = false,
applyImmediately = false,
hint = 'Watch state will sync to the active accounts in your group as you watch new content.'
} = options;
if (!window.ModalSystem?.create) {
onComplete?.();
return;
}
if (watchTogetherModal?.isOpen) {
watchTogetherModal.close();
}
const users = getSessionUsers();
// Manage / prefs entry can open with an empty group so users can add accounts.
if (!users.length && !showRemove) {
onComplete?.();
return;
}
const pendingEnabledIds = readDeviceState().enabledUserIds
.filter((id) => users.some((u) => u.userId === id));
const contentHtml = buildWatchTogetherPickerBodyHtml(users, pendingEnabledIds, {
showRemove,
hint
});
const showAddUser = options.showAddUser !== false;
const modalId = showRemove ? MODAL_ID : WATCH_TOGETHER_MODAL_ID;
watchTogetherModal = window.ModalSystem.create({
id: modalId,
title,
content: contentHtml,
footer: showAddUser
? `
<div class="mus-wt-footer-actions" style="display:flex;flex-direction:row;flex-wrap:wrap;gap:0.5em;width:100%;justify-content:flex-end;align-items:center;">
<button type="button" class="raised button-submit emby-button" id="mus-wt-add-user">Add User</button>
</div>
`
: null,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
watchTogetherModal = modalInstance;
if (showRemove) activeModal = modalInstance;
if (modalInstance?.dialog) {
modalInstance.dialog.style.maxWidth = '480px';
modalInstance.dialog.style.width = '92vw';
}
const root = modalInstance.dialogContent;
const rebind = () => {
const nextUsers = getSessionUsers();
const deviceEnabled = new Set(readDeviceState().enabledUserIds);
// Drop removed users from pending selection
for (let i = pendingEnabledIds.length - 1; i >= 0; i--) {
if (!nextUsers.some((u) => u.userId === pendingEnabledIds[i])) {
pendingEnabledIds.splice(i, 1);
}
}
// Pick up newly added / device-enabled users (e.g. after Add User)
nextUsers.forEach((u) => {
if (deviceEnabled.has(u.userId) && !pendingEnabledIds.includes(u.userId)) {
pendingEnabledIds.push(u.userId);
}
});
const body = root.querySelector('.mus-watch-together-prompt');
if (!body) return;
body.outerHTML = buildWatchTogetherPickerBodyHtml(nextUsers, pendingEnabledIds, {
showRemove,
hint
});
bindAvatarToggleHandlers(root, pendingEnabledIds, {
applyImmediately,
onRemove: () => rebind()
});
refreshPreferencesMenuLinks();
};
refreshWatchTogetherPicker = rebind;
bindAvatarToggleHandlers(root, pendingEnabledIds, {
applyImmediately,
onRemove: () => rebind()
});
modalInstance.dialogFooter?.querySelector('#mus-wt-add-user')?.addEventListener('click', (e) => {
e.preventDefault();
openAddUserModal();
});
}
});
if (watchTogetherModal) {
watchTogetherModal.onClose = () => {
// Dismiss (X / backdrop / Escape) applies the current selection + session stamp.
stampLastSelectionAt([...pendingEnabledIds]);
watchTogetherModal = null;
refreshWatchTogetherPicker = null;
if (showRemove) activeModal = null;
onComplete?.();
};
}
}
async function fetchCandidateUsersForAdmin() {
const primaryId = getPrimaryUserId();
const existing = getSessionUserIds();
let users = [];
try {
if (typeof ApiClient?.getUsers === 'function') {
users = await ApiClient.getUsers();
} else {
const serverUrl = ApiClient.serverAddress();
const response = await fetch(`${serverUrl}/Users`, {
method: 'GET',
headers: {
Authorization: window.apiHelper?.getAuthHeader?.() || authHeaderForToken(ApiClient.accessToken?.() || ''),
Accept: 'application/json'
}
});
if (!response.ok) throw new Error(`Users ${response.status}`);
users = await response.json();
}
} catch (e) {
ERR('Failed to load users for admin Watch Together add:', e);
throw e;
}
if (!Array.isArray(users)) users = [];
return users
.filter((u) => u?.Id && u.Id !== primaryId && !existing.has(u.Id))
.map((u) => ({ userId: u.Id, name: u.Name || u.Id }))
.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
}
function buildAddUserModalContentHtml() {
const { buildTextInput } = getUi();
const usernameField = typeof buildTextInput === 'function'
? buildTextInput('mus-add-username', '', 'Username', 'text')
: '';
const passwordField = typeof buildTextInput === 'function'
? buildTextInput('mus-add-password', '', 'Password', 'password')
: '';
return `
<div class="mus-add-user-modal-body" style="padding: 0.25em 0;">
${usernameField}
${passwordField}
<div id="mus-add-user-error" class="listItemBodyText" style="color: #f44336; display: none; margin: 0.5em 0;"></div>
<div style="display: flex; gap: 0.5em; margin-top: 1em;">
<button type="button" class="raised button-submit block emby-button" id="mus-add-authenticate">Authenticate</button>
<button type="button" class="raised block emby-button" id="mus-add-cancel">Cancel</button>
</div>
</div>
`;
}
function buildAdminAddUserModalContentHtml() {
return `
<div class="mus-add-user-modal-body" style="padding: 0.25em 0;">
<div class="inputContainer">
<select id="mus-add-user-select" class="emby-select-withcolor emby-select" style="width: 100%;" disabled>
<option value="">Loading users…</option>
</select>
</div>
<div id="mus-add-user-error" class="listItemBodyText" style="color: #f44336; display: none; margin: 0.5em 0;"></div>
<div style="display: flex; gap: 0.5em; margin-top: 1em;">
<button type="button" class="raised button-submit block emby-button" id="mus-add-admin-add" disabled>Add</button>
<button type="button" class="raised block emby-button" id="mus-add-cancel">Cancel</button>
</div>
</div>
`;
}
function populateAdminUserSelect(root, candidates) {
const select = root?.querySelector('#mus-add-user-select');
const addBtn = root?.querySelector('#mus-add-admin-add');
if (!select) return;
select.innerHTML = '';
if (!candidates.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No users available to add';
select.appendChild(opt);
select.disabled = true;
if (addBtn) addBtn.disabled = true;
return;
}
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = 'Select a user…';
select.appendChild(placeholder);
candidates.forEach((u) => {
const opt = document.createElement('option');
opt.value = u.userId;
opt.textContent = u.name;
opt.setAttribute('data-name', u.name);
select.appendChild(opt);
});
select.disabled = false;
if (addBtn) addBtn.disabled = false;
}
async function submitAdminAddUser(modalInstance) {
const root = modalInstance?.dialogContent;
if (!root) return;
const select = root.querySelector('#mus-add-user-select');
const errorEl = root.querySelector('#mus-add-user-error');
const addBtn = root.querySelector('#mus-add-admin-add');
const userId = select?.value || '';
const name = select?.selectedOptions?.[0]?.getAttribute('data-name')
|| select?.selectedOptions?.[0]?.textContent
|| userId;
if (errorEl) {
errorEl.style.display = 'none';
errorEl.textContent = '';
}
if (!userId) {
if (errorEl) {
errorEl.textContent = 'Select a user to add.';
errorEl.style.display = 'block';
}
return;
}
if (userId === getPrimaryUserId()) {
if (errorEl) {
errorEl.textContent = 'Cannot add the currently signed-in user to the session';
errorEl.style.display = 'block';
}
return;
}
if (addBtn) addBtn.disabled = true;
try {
await upsertSessionUser({ userId, name, viaAdmin: true });
modalInstance.close();
if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) {
refreshWatchTogetherPicker();
}
window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`);
} catch (err) {
if (errorEl) {
errorEl.textContent = err?.message || 'Failed to add user';
errorEl.style.display = 'block';
}
ERR('Admin add user failed:', err);
if (addBtn) addBtn.disabled = false;
}
}
async function submitAddUserAuth(modalInstance) {
const root = modalInstance?.dialogContent;
if (!root) return;
const username = root.querySelector('#mus-add-username')?.value?.trim() || '';
const password = root.querySelector('#mus-add-password')?.value ?? '';
const errorEl = root.querySelector('#mus-add-user-error');
const authBtn = root.querySelector('#mus-add-authenticate');
if (errorEl) {
errorEl.style.display = 'none';
errorEl.textContent = '';
}
if (!username) {
if (errorEl) {
errorEl.textContent = 'Username is required.';
errorEl.style.display = 'block';
}
return;
}
if (authBtn) authBtn.disabled = true;
try {
const result = await authenticateByName(username, password);
const userId = result?.User?.Id || result?.UserId;
const name = result?.User?.Name || username;
const accessToken = result?.AccessToken || result?.accessToken;
if (!userId || !accessToken) {
throw new Error('Authentication response missing user or token');
}
if (userId === getPrimaryUserId()) {
throw new Error('Cannot add the currently signed-in user to the session');
}
await upsertSessionUser({ userId, name, accessToken });
const pwEl = root.querySelector('#mus-add-password');
const userEl = root.querySelector('#mus-add-username');
if (pwEl) pwEl.value = '';
if (userEl) userEl.value = '';
modalInstance.close();
if (typeof refreshWatchTogetherPicker === 'function' && watchTogetherModal?.isOpen) {
refreshWatchTogetherPicker();
}
window.KefinTweaksToaster?.toast?.(`Added ${name} to Watch Together group`);
} catch (err) {
if (errorEl) {
errorEl.textContent = err?.message || 'Authentication failed';
errorEl.style.display = 'block';
}
ERR('Add user failed:', err);
} finally {
if (authBtn) authBtn.disabled = false;
}
}
function bindAddUserModalHandlers(modalInstance) {
const root = modalInstance?.dialogContent;
if (!root) return;
root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => {
e.preventDefault();
modalInstance.close();
});
root.querySelector('#mus-add-authenticate')?.addEventListener('click', (e) => {
e.preventDefault();
submitAddUserAuth(modalInstance);
});
const handleEnter = (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
submitAddUserAuth(modalInstance);
};
root.querySelector('#mus-add-username')?.addEventListener('keydown', handleEnter);
root.querySelector('#mus-add-password')?.addEventListener('keydown', handleEnter);
setTimeout(() => root.querySelector('#mus-add-username')?.focus(), 50);
}
function bindAdminAddUserModalHandlers(modalInstance) {
const root = modalInstance?.dialogContent;
if (!root) return;
root.querySelector('#mus-add-cancel')?.addEventListener('click', (e) => {
e.preventDefault();
modalInstance.close();
});
root.querySelector('#mus-add-admin-add')?.addEventListener('click', (e) => {
e.preventDefault();
submitAdminAddUser(modalInstance);
});
root.querySelector('#mus-add-user-select')?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
submitAdminAddUser(modalInstance);
});
}
async function openAddUserModal() {
if (!window.ModalSystem?.create) {
alert('Modal system not available.');
return;
}
if (addUserModal?.isOpen) {
addUserModal.close();
}
let isAdminUser = false;
try {
isAdminUser = !!(await window.apiHelper?.isAdmin?.());
} catch (_) {
isAdminUser = false;
}
addUserModal = window.ModalSystem.create({
id: ADD_USER_MODAL_ID,
title: null,
content: isAdminUser ? buildAdminAddUserModalContentHtml() : buildAddUserModalContentHtml(),
footer: null,
showCloseButton: false,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: async (modalInstance) => {
addUserModal = modalInstance;
if (modalInstance?.dialog) {
modalInstance.dialog.style.maxWidth = '400px';
modalInstance.dialog.style.width = '90vw';
}
if (isAdminUser) {
bindAdminAddUserModalHandlers(modalInstance);
const errorEl = modalInstance.dialogContent?.querySelector('#mus-add-user-error');
try {
const candidates = await fetchCandidateUsersForAdmin();
populateAdminUserSelect(modalInstance.dialogContent, candidates);
setTimeout(() => modalInstance.dialogContent?.querySelector('#mus-add-user-select')?.focus(), 50);
} catch (err) {
populateAdminUserSelect(modalInstance.dialogContent, []);
if (errorEl) {
errorEl.textContent = err?.message || 'Failed to load users';
errorEl.style.display = 'block';
}
}
} else {
bindAddUserModalHandlers(modalInstance);
}
}
});
if (addUserModal) {
addUserModal.onClose = () => {
addUserModal = null;
};
}
}
function openManageModal() {
openWatchTogetherPrompt({
title: 'Watch Together',
showRemove: true,
applyImmediately: true
});
}
function getVisiblePreferencesPage() {
return document.querySelector('.userPreferencesPage:not(.hide)');
}
function getVisiblePreferencesUserSection(preferencesPage = null) {
const page = preferencesPage || getVisiblePreferencesPage();
if (!page) return null;
return page.querySelector('.userSection.verticalSection');
}
function getWatchTogetherMenuIcon() {
return 'group';
}
function updateWatchStateSyncListIcon(link) {
const iconEl = link?.querySelector('.listItemIcon');
if (!iconEl) return;
iconEl.className = `material-icons listItemIcon listItemIcon-transparent ${getWatchTogetherMenuIcon()}`;
}
function createPreferencesListLink({ markerAttr, icon, title, titleHtml, subtitle, subtitleHtml, onClick }) {
const link = document.createElement('a');
link.is = 'emby-linkbutton';
link.setAttribute('is', 'emby-linkbutton');
link.setAttribute('data-ripple', 'false');
link.href = '#';
link.style.cssText = 'display:block;padding:0;margin:0';
link.className = 'listItem-border emby-button';
link.setAttribute(markerAttr, 'true');
const titleContent = titleHtml
? `<div class="listItemBodyText mus-watch-sync-title">${titleHtml}</div>`
: `<div class="listItemBodyText">${escapeHtml(title)}</div>`;
const subtitleContent = subtitleHtml
? `<div class="listItemBodyText secondary mus-watch-sync-names">${subtitleHtml}</div>`
: (subtitle ? `<div class="listItemBodyText secondary">${escapeHtml(subtitle)}</div>` : '');
link.innerHTML = `
<div class="listItem">
<span class="material-icons listItemIcon listItemIcon-transparent ${icon}" aria-hidden="true"></span>
<div class="listItemBody">
${titleContent}
${subtitleContent}
</div>
</div>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
});
return link;
}
function refreshPreferencesMenuLinks() {
const preferencesPage = getVisiblePreferencesPage();
if (!preferencesPage) return;
const userSection = getVisiblePreferencesUserSection(preferencesPage);
if (!userSection) return;
// Legacy prefs entry removed from the menu; clean up if still present.
preferencesPage.querySelector('[data-kefin-add-user-session]')?.remove();
let syncLink = preferencesPage.querySelector('[data-kefin-watch-state-sync]');
if (!syncLink) {
syncLink = createPreferencesListLink({
markerAttr: 'data-kefin-watch-state-sync',
icon: getWatchTogetherMenuIcon(),
title: 'Watch Together',
onClick: openManageModal
});
const signOut = userSection.querySelector('.btnLogout');
if (signOut && signOut.parentNode === userSection) {
userSection.insertBefore(syncLink, signOut);
} else {
userSection.appendChild(syncLink);
}
return;
}
updateWatchStateSyncListIcon(syncLink);
}
function injectPreferencesMenuLinks() {
const preferencesPage = getVisiblePreferencesPage();
if (!preferencesPage) return;
const userSection = getVisiblePreferencesUserSection(preferencesPage);
if (!userSection) return;
refreshPreferencesMenuLinks();
LOG('Preferences menu links injected');
}
function registerPreferencesPageHandler() {
if (!window.KefinTweaksUtils?.onViewPage) {
setTimeout(registerPreferencesPageHandler, 1000);
return;
}
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
if (!hash || !(
hash.includes('mypreferencesmenu')
|| hash.includes('userpreferences')
|| hash.includes('preferences')
)) {
return;
}
setTimeout(injectPreferencesMenuLinks, 300);
setTimeout(injectPreferencesMenuLinks, 800);
}, {
pages: ['mypreferencesmenu']
});
LOG('Preferences onViewPage handler registered');
}
function shouldPromptWatchTogether() {
if (!getSessionUsers().length) return false;
if (inContinuousVideoSession) return false;
const { lastSelectionAt } = readDeviceState();
if (lastSelectionAt && (Date.now() - lastSelectionAt) < getSessionTimeoutMs()) {
return false;
}
return true;
}
function handleVideoPageEnter() {
inContinuousVideoSession = true;
addWatchTogetherOsdButton();
if (!shouldPromptWatchTogether()) {
updateSyncListenerState();
return;
}
openWatchTogetherPrompt({
onComplete: () => {
updateSyncListenerState();
addWatchTogetherOsdButton();
}
});
}
function handleVideoPageLeave() {
inContinuousVideoSession = false;
}
function watchTogetherOsdButtonExists() {
return !!document.querySelector('#videoOsdPage:not(.hide) button.btnWatchTogether');
}
function createWatchTogetherOsdButton() {
const button = document.createElement('button');
button.setAttribute('is', 'paper-icon-button-light');
button.className = 'btnWatchTogether autoSize paper-icon-button-light';
button.title = 'Watch Together';
const icon = document.createElement('span');
icon.className = 'xlargePaperIconButton material-icons group';
icon.setAttribute('aria-hidden', 'true');
button.appendChild(icon);
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openManageModal();
});
return button;
}
function addWatchTogetherOsdButton() {
if (isAddingOsdButton) return;
if (watchTogetherOsdButtonExists()) return;
if (!getSessionUsers().length && !getEnabledSessionUsers().length) {
// Still show button if group empty so users can manage from OSD after adding via prefs;
// only skip when feature has no group AND we're not on video — actually always show if hydrated group could be empty.
}
isAddingOsdButton = true;
try {
const buttonsContainer = document.querySelector('#videoOsdPage:not(.hide) .osdControls > .buttons');
if (!buttonsContainer) return;
const ratingButton = buttonsContainer.querySelector('.btnUserRating');
if (!ratingButton) return;
const btn = createWatchTogetherOsdButton();
buttonsContainer.insertBefore(btn, ratingButton);
} finally {
isAddingOsdButton = false;
}
}
function registerVideoPageHandler() {
if (!window.KefinTweaksUtils?.onViewPage) {
setTimeout(registerVideoPageHandler, 1000);
return;
}
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
const onVideo = hash && (hash.includes('#/video') || hash.includes('/video'));
if (onVideo) {
setTimeout(handleVideoPageEnter, 400);
setTimeout(addWatchTogetherOsdButton, 900);
} else if (inContinuousVideoSession) {
handleVideoPageLeave();
}
}, {
pages: ['video']
});
if (window.location.hash.includes('#/video') || window.location.hash.includes('/video')) {
setTimeout(handleVideoPageEnter, 500);
}
LOG('Video onViewPage handler registered');
}
async function initialize() {
try {
if (window.userHelper?.waitForLogin) {
await window.userHelper.waitForLogin();
}
injectStyles();
await hydrateGroupCache();
updateHeaderMultiUserIndicator();
updateSyncListenerState();
registerPreferencesPageHandler();
registerVideoPageHandler();
LOG('Initialized');
} catch (e) {
ERR('Initialization failed:', e);
}
}
window.KefinMultiUserSession = {
openManageModal,
openWatchTogetherPrompt,
getSessionUsers,
getEnabledSessionUsers,
updateSyncListenerState,
hydrateGroupCache
};
initialize();
})();