Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d139034044 | ||
|
|
5b049c9cad | ||
|
|
8ad9c5b320 |
@@ -22,7 +22,13 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
<link
|
||||
id="favicon"
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/public/favicon-32x32.png"
|
||||
/>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -34,52 +40,52 @@
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="./public/res/apple/apple-touch-icon-57x57.png"
|
||||
href="/public/res/apple/apple-touch-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="./public/res/apple/apple-touch-icon-60x60.png"
|
||||
href="/public/res/apple/apple-touch-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="./public/res/apple/apple-touch-icon-72x72.png"
|
||||
href="/public/res/apple/apple-touch-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="./public/res/apple/apple-touch-icon-76x76.png"
|
||||
href="/public/res/apple/apple-touch-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="./public/res/apple/apple-touch-icon-114x114.png"
|
||||
href="/public/res/apple/apple-touch-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="./public/res/apple/apple-touch-icon-120x120.png"
|
||||
href="/public/res/apple/apple-touch-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="./public/res/apple/apple-touch-icon-144x144.png"
|
||||
href="/public/res/apple/apple-touch-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="./public/res/apple/apple-touch-icon-152x152.png"
|
||||
href="/public/res/apple/apple-touch-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="167x167"
|
||||
href="./public/res/apple/apple-touch-icon-167x167.png"
|
||||
href="/public/res/apple/apple-touch-icon-167x167.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="./public/res/apple/apple-touch-icon-180x180.png"
|
||||
href="/public/res/apple/apple-touch-icon-180x180.png"
|
||||
/>
|
||||
</head>
|
||||
<body id="appBody">
|
||||
|
||||
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 516 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 606 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import CinnySVG from '../../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../../public/res/botsu-b.png';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -38,8 +38,8 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Box shrink="No">
|
||||
<img
|
||||
style={{ width: toRem(60), height: toRem(60) }}
|
||||
src={CinnySVG}
|
||||
alt="Cinny logo"
|
||||
src={BotsuLogoImage}
|
||||
alt="BOTSU logo"
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="300">
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../public/res/botsu-b.png';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -134,7 +134,7 @@ export function AuthLayout() {
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<img className={css.AuthLogo} src={BotsuLogoImage} alt="BOTSU Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { color, config } from 'folds';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/svg/cinny.svg';
|
||||
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
|
||||
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
|
||||
import LogoSVG from '../../../../public/res/botsu-b.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/botsu-b-unread.png';
|
||||
import LogoHighlightSVG from '../../../../public/res/botsu-b-highlight.png';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import BotsuLogoImage from '../../../../public/res/botsu-b.png';
|
||||
|
||||
export function WelcomePage() {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ export function WelcomePage() {
|
||||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<img width="70" height="70" src={CinnySVG} alt="Cinny Logo" />}
|
||||
icon={<img width="70" height="70" src={BotsuLogoImage} alt="BOTSU Logo" />}
|
||||
title="Welcome to Cinny"
|
||||
subTitle={
|
||||
<span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -24,6 +25,11 @@ const pixelCanvasUrl = new URL('../start/BotsuPixelCanvas.tsx', import.meta.url)
|
||||
const startCssUrl = new URL('../start/start.css', import.meta.url);
|
||||
const godotUrl = new URL('./BotsuGodot.tsx', import.meta.url);
|
||||
const viteConfigUrl = new URL('../../../vite.config.js', import.meta.url);
|
||||
const authLayoutUrl = new URL('../../app/pages/auth/AuthLayout.tsx', import.meta.url);
|
||||
const welcomePageUrl = new URL('../../app/pages/client/WelcomePage.tsx', import.meta.url);
|
||||
const aboutPageUrl = new URL('../../app/features/settings/about/About.tsx', import.meta.url);
|
||||
const botsuBrandUrl = new URL('../../../public/res/botsu-b.png', import.meta.url);
|
||||
const indexHtmlUrl = new URL('../../../index.html', import.meta.url);
|
||||
|
||||
const getRuleBody = (css: string, selector: string): string => {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -32,6 +38,34 @@ const getRuleBody = (css: string, selector: string): string => {
|
||||
return match[1];
|
||||
};
|
||||
|
||||
test('every former Cinny logo surface uses the supplied BOTSU image', async () => {
|
||||
const [auth, welcome, about, nonUi, brand, indexHtml] = await Promise.all([
|
||||
readFile(authLayoutUrl, 'utf8'),
|
||||
readFile(welcomePageUrl, 'utf8'),
|
||||
readFile(aboutPageUrl, 'utf8'),
|
||||
readFile(clientNonUiUrl, 'utf8'),
|
||||
readFile(botsuBrandUrl),
|
||||
readFile(indexHtmlUrl, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
createHash('sha256').update(brand).digest('hex'),
|
||||
'01ba2d35ced4d030d88a7d31597b370894356aed973507b04fff839ba637b382'
|
||||
);
|
||||
for (const source of [auth, welcome, about, nonUi]) {
|
||||
assert.ok(source.includes('botsu-b.png'));
|
||||
assert.equal(source.includes('cinny.svg'), false);
|
||||
}
|
||||
assert.ok(nonUi.includes('botsu-b-unread.png'));
|
||||
assert.ok(nonUi.includes('botsu-b-highlight.png'));
|
||||
assert.ok(indexHtml.includes('rel="icon"'));
|
||||
assert.ok(indexHtml.includes('type="image/png"'));
|
||||
assert.ok(indexHtml.includes('sizes="32x32"'));
|
||||
assert.ok(indexHtml.includes('href="/public/favicon-32x32.png"'));
|
||||
assert.equal(indexHtml.includes('rel="shortcut icon"'), false);
|
||||
assert.equal(indexHtml.includes('href="./public/'), false);
|
||||
});
|
||||
|
||||
test('BOTSU shell leaves room for the active call footer', async () => {
|
||||
const css = await readFile(shellCssUrl, 'utf8');
|
||||
const shellRule = getRuleBody(css, '.botsu-shell');
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
PlayIcon,
|
||||
SpeakerHighIcon,
|
||||
SpeakerSlashIcon,
|
||||
TwitchLogoIcon,
|
||||
UsersThreeIcon,
|
||||
XIcon,
|
||||
YoutubeLogoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {
|
||||
createEmptyBotsuWatchSession,
|
||||
@@ -45,7 +47,13 @@ import {
|
||||
type BotsuWatchSnapshot,
|
||||
} from './model';
|
||||
import { createBrowserJellyfinClient, type JellyfinItem, type JellyfinPlayback } from './jellyfin';
|
||||
import { createJellyfinWatchUrl, normalizeWatchSourceUrl, parseWatchSource } from './providers';
|
||||
import {
|
||||
createJellyfinWatchUrl,
|
||||
createTwitchAccountUrl,
|
||||
createYouTubePremiumUrl,
|
||||
normalizeWatchSourceUrl,
|
||||
parseWatchSource,
|
||||
} from './providers';
|
||||
import { WatchEmbedPlayer, type WatchPlaybackController } from './WatchEmbedPlayer';
|
||||
import './watch-room.css';
|
||||
|
||||
@@ -68,6 +76,8 @@ type WatchPreferences = {
|
||||
type WatchThemeStyle = CSSProperties & Record<`--botsu-watch-${string}`, string>;
|
||||
|
||||
const WATCH_PREFERENCES_KEY = 'botsu.watch.preferences.v1';
|
||||
const YOUTUBE_ACCOUNT_MODE_KEY = 'botsu.watch.youtube-account.v1';
|
||||
const TWITCH_ACCOUNT_MODE_KEY = 'botsu.watch.twitch-account.v1';
|
||||
const WATCH_CONTROLS_IDLE_MS = 2_800;
|
||||
const EMPTY_SESSION = createEmptyBotsuWatchSession();
|
||||
|
||||
@@ -105,6 +115,22 @@ const loadWatchPreferences = (): WatchPreferences => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadYouTubeAccountMode = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(YOUTUBE_ACCOUNT_MODE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTwitchAccountMode = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(TWITCH_ACCOUNT_MODE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readWatchRoomState = (room: Room, now = Date.now()): WatchRoomState => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.BotsuWatchSession, '');
|
||||
if (!event || Array.isArray(event)) {
|
||||
@@ -378,6 +404,158 @@ function SourceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type AccountProvider = 'youtube' | 'twitch';
|
||||
|
||||
const accountProviderCopy = {
|
||||
youtube: {
|
||||
title: 'YouTube Premium',
|
||||
introTitle: 'Profitez de la session YouTube de ce navigateur',
|
||||
introText:
|
||||
'Votre abonnement reste lié à YouTube. BOTSU ne demande jamais votre mot de passe ni l’accès à votre compte Google.',
|
||||
embedText:
|
||||
'Active les cookies YouTube pour retrouver Premium si vous êtes connecté dans ce navigateur. Les règles anti-cookies du navigateur peuvent toutefois le bloquer.',
|
||||
fallbackTitle: 'Ouverture sécurisée sur YouTube',
|
||||
fallbackText:
|
||||
'L’isolation de cette version de BOTSU empêche un lecteur connecté. YouTube peut s’ouvrir à la position commune dans un onglet où votre compte Premium reste actif.',
|
||||
privacyText:
|
||||
'Ce réglage reste sur cet appareil. Aucun cookie, jeton Google ou statut Premium n’est partagé dans Matrix.',
|
||||
loginUrl: 'https://www.youtube.com/',
|
||||
loginLabel: 'Se connecter à YouTube',
|
||||
openLabel: 'Ouvrir avec YouTube Premium',
|
||||
},
|
||||
twitch: {
|
||||
title: 'Compte Twitch',
|
||||
introTitle: 'Utilisez la session Twitch de ce navigateur',
|
||||
introText:
|
||||
'Vos abonnements et Twitch Turbo restent gérés par Twitch. BOTSU ne demande jamais votre mot de passe ni l’accès à votre compte.',
|
||||
embedText:
|
||||
'Active les cookies Twitch pour retrouver votre session, Turbo et les avantages de vos abonnements. Les règles anti-cookies du navigateur peuvent toutefois le bloquer.',
|
||||
fallbackTitle: 'Ouverture sécurisée sur Twitch',
|
||||
fallbackText:
|
||||
'L’isolation de cette version de BOTSU empêche un lecteur connecté. Twitch peut s’ouvrir sur la chaîne ou à la position commune de la VOD dans un onglet où votre compte reste actif.',
|
||||
privacyText:
|
||||
'Ce réglage reste sur cet appareil. Aucun cookie, jeton Twitch ou statut Turbo/abonnement n’est partagé dans Matrix.',
|
||||
loginUrl: 'https://www.twitch.tv/login',
|
||||
loginLabel: 'Se connecter à Twitch',
|
||||
openLabel: 'Ouvrir avec mon compte Twitch',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function ProviderAccountDialog({
|
||||
provider,
|
||||
accountMode,
|
||||
canEmbedAccount,
|
||||
accountUrl,
|
||||
onAccountModeChange,
|
||||
onClose,
|
||||
onOpenProvider,
|
||||
}: {
|
||||
provider: AccountProvider;
|
||||
accountMode: boolean;
|
||||
canEmbedAccount: boolean;
|
||||
accountUrl: string;
|
||||
onAccountModeChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
onOpenProvider: (url: string) => void;
|
||||
}) {
|
||||
const copy = accountProviderCopy[provider];
|
||||
const dialogTitleId = `botsu-watch-${provider}-account-title`;
|
||||
const Logo = provider === 'youtube' ? YoutubeLogoIcon : TwitchLogoIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="botsu-watch-source-overlay" role="presentation" onMouseDown={onClose}>
|
||||
<section
|
||||
aria-labelledby={dialogTitleId}
|
||||
aria-modal="true"
|
||||
className={`botsu-watch-source-dialog botsu-watch-provider-dialog botsu-watch-provider-dialog--${provider}`}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="botsu-watch-eyebrow">LECTURE PERSONNELLE</span>
|
||||
<h2 id={dialogTitleId}>{copy.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Fermer"
|
||||
className="botsu-watch-icon-button"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<XIcon aria-hidden size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="botsu-watch-provider-intro">
|
||||
<Logo aria-hidden size={38} weight="fill" />
|
||||
<div>
|
||||
<strong>{copy.introTitle}</strong>
|
||||
<p>{copy.introText}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEmbedAccount ? (
|
||||
<div className="botsu-watch-provider-option">
|
||||
<div>
|
||||
<strong>Compte dans le lecteur intégré</strong>
|
||||
<p>{copy.embedText}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={accountMode}
|
||||
className="botsu-watch-button botsu-watch-provider-toggle"
|
||||
onClick={() => onAccountModeChange(!accountMode)}
|
||||
type="button"
|
||||
>
|
||||
{accountMode ? 'Activé' : 'Activer'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="botsu-watch-provider-option" role="status">
|
||||
<div>
|
||||
<strong>{copy.fallbackTitle}</strong>
|
||||
<p>{copy.fallbackText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="botsu-watch-source-note botsu-watch-provider-note">{copy.privacyText}</p>
|
||||
|
||||
<footer className="botsu-watch-provider-actions">
|
||||
{canEmbedAccount && (
|
||||
<button
|
||||
className="botsu-watch-button"
|
||||
onClick={() => onOpenProvider(copy.loginUrl)}
|
||||
type="button"
|
||||
>
|
||||
{copy.loginLabel}
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
<button className="botsu-watch-button" onClick={onClose} type="button">
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
className="botsu-watch-button botsu-watch-button--primary"
|
||||
onClick={() => onOpenProvider(accountUrl)}
|
||||
type="button"
|
||||
>
|
||||
<Logo aria-hidden size={18} weight="fill" />
|
||||
{copy.openLabel}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotsuWatchRoomView({
|
||||
room,
|
||||
chatContent,
|
||||
@@ -412,6 +590,10 @@ export function BotsuWatchRoomView({
|
||||
const [captions, setCaptions] = useState(false);
|
||||
const [captionsAvailable, setCaptionsAvailable] = useState(false);
|
||||
const [sourceDialog, setSourceDialog] = useState(false);
|
||||
const [youtubeAccountDialog, setYoutubeAccountDialog] = useState(false);
|
||||
const [youtubeAccountMode, setYoutubeAccountMode] = useState(loadYouTubeAccountMode);
|
||||
const [twitchAccountDialog, setTwitchAccountDialog] = useState(false);
|
||||
const [twitchAccountMode, setTwitchAccountMode] = useState(loadTwitchAccountMode);
|
||||
const [sourceError, setSourceError] = useState<string>();
|
||||
const [mediaError, setMediaError] = useState<string>();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -447,6 +629,10 @@ export function BotsuWatchRoomView({
|
||||
: undefined;
|
||||
const joinedMembers = useMemo(() => room.getJoinedMembers(), [room, watchState]);
|
||||
const canSeek = Number.isFinite(duration) && duration > 0;
|
||||
const canEmbedProviderAccount = !window.crossOriginIsolated;
|
||||
const youtubeAccountActive = youtubeAccountMode && canEmbedProviderAccount;
|
||||
const twitchAccountActive = twitchAccountMode && canEmbedProviderAccount;
|
||||
const accountDialogOpen = youtubeAccountDialog || twitchAccountDialog;
|
||||
|
||||
const clearControlsIdleTimer = useCallback(() => {
|
||||
if (controlsIdleTimerRef.current === undefined) return;
|
||||
@@ -456,12 +642,12 @@ export function BotsuWatchRoomView({
|
||||
|
||||
const scheduleControlsHide = useCallback(() => {
|
||||
clearControlsIdleTimer();
|
||||
if (!immersive || sourceDialog) return;
|
||||
if (!immersive || sourceDialog || accountDialogOpen) return;
|
||||
controlsIdleTimerRef.current = window.setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
controlsIdleTimerRef.current = undefined;
|
||||
}, WATCH_CONTROLS_IDLE_MS);
|
||||
}, [clearControlsIdleTimer, immersive, sourceDialog]);
|
||||
}, [accountDialogOpen, clearControlsIdleTimer, immersive, sourceDialog]);
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
@@ -713,6 +899,29 @@ export function BotsuWatchRoomView({
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(YOUTUBE_ACCOUNT_MODE_KEY, String(youtubeAccountMode));
|
||||
} catch {
|
||||
// The account mode remains available for this tab when storage is unavailable.
|
||||
}
|
||||
}, [youtubeAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(TWITCH_ACCOUNT_MODE_KEY, String(twitchAccountMode));
|
||||
} catch {
|
||||
// The account mode remains available for this tab when storage is unavailable.
|
||||
}
|
||||
}, [twitchAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDescriptor?.kind !== 'youtube') setYoutubeAccountDialog(false);
|
||||
if (sourceDescriptor?.kind !== 'twitch-live' && sourceDescriptor?.kind !== 'twitch-video') {
|
||||
setTwitchAccountDialog(false);
|
||||
}
|
||||
}, [sourceDescriptor?.kind]);
|
||||
|
||||
const sendSession = useCallback(
|
||||
async (nextValue: BotsuWatchSession) => {
|
||||
const next = parseBotsuWatchSession(nextValue);
|
||||
@@ -855,8 +1064,12 @@ export function BotsuWatchRoomView({
|
||||
setCaptions(next);
|
||||
};
|
||||
|
||||
const openProvider = useCallback((url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!immersive || sourceDialog) return undefined;
|
||||
if (!immersive || sourceDialog || accountDialogOpen) return undefined;
|
||||
const handleKeyboardShortcut = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
@@ -895,6 +1108,14 @@ export function BotsuWatchRoomView({
|
||||
};
|
||||
|
||||
const displayPosition = seekDraft ?? position;
|
||||
const youtubePremiumUrl =
|
||||
sourceDescriptor?.kind === 'youtube'
|
||||
? createYouTubePremiumUrl(sourceDescriptor.videoId, displayPosition)
|
||||
: undefined;
|
||||
const twitchAccountUrl =
|
||||
sourceDescriptor?.kind === 'twitch-live' || sourceDescriptor?.kind === 'twitch-video'
|
||||
? createTwitchAccountUrl(sourceDescriptor, displayPosition)
|
||||
: undefined;
|
||||
const senderName = watchState.senderId
|
||||
? room.getMember(watchState.senderId)?.name ?? watchState.senderId
|
||||
: undefined;
|
||||
@@ -948,7 +1169,9 @@ export function BotsuWatchRoomView({
|
||||
onError={setMediaError}
|
||||
onReady={() => void applyPlaybackState(watchState.snapshot)}
|
||||
source={embeddedSource}
|
||||
twitchCredentialMode={twitchAccountActive ? 'account' : 'anonymous'}
|
||||
volume={preferences.volume}
|
||||
youtubeCredentialMode={youtubeAccountActive ? 'account' : 'anonymous'}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
@@ -1123,6 +1346,31 @@ export function BotsuWatchRoomView({
|
||||
</div>
|
||||
|
||||
<div className="botsu-watch-secondary-actions">
|
||||
{sourceDescriptor?.kind === 'youtube' && (
|
||||
<button
|
||||
aria-label="Compte YouTube Premium"
|
||||
aria-pressed={youtubeAccountActive}
|
||||
className="botsu-watch-button botsu-watch-account-button botsu-watch-account-button--youtube"
|
||||
onClick={() => setYoutubeAccountDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<YoutubeLogoIcon aria-hidden size={20} weight="fill" />
|
||||
<span>Premium</span>
|
||||
</button>
|
||||
)}
|
||||
{(sourceDescriptor?.kind === 'twitch-live' ||
|
||||
sourceDescriptor?.kind === 'twitch-video') && (
|
||||
<button
|
||||
aria-label="Compte Twitch"
|
||||
aria-pressed={twitchAccountActive}
|
||||
className="botsu-watch-button botsu-watch-account-button botsu-watch-account-button--twitch"
|
||||
onClick={() => setTwitchAccountDialog(true)}
|
||||
type="button"
|
||||
>
|
||||
<TwitchLogoIcon aria-hidden size={20} weight="fill" />
|
||||
<span>Compte</span>
|
||||
</button>
|
||||
)}
|
||||
{!jellyfinPlayback && (
|
||||
<button
|
||||
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
|
||||
@@ -1248,6 +1496,28 @@ export function BotsuWatchRoomView({
|
||||
session={session}
|
||||
/>
|
||||
)}
|
||||
{youtubeAccountDialog && youtubePremiumUrl && (
|
||||
<ProviderAccountDialog
|
||||
accountUrl={youtubePremiumUrl}
|
||||
accountMode={youtubeAccountMode}
|
||||
canEmbedAccount={canEmbedProviderAccount}
|
||||
onAccountModeChange={setYoutubeAccountMode}
|
||||
onClose={() => setYoutubeAccountDialog(false)}
|
||||
onOpenProvider={openProvider}
|
||||
provider="youtube"
|
||||
/>
|
||||
)}
|
||||
{twitchAccountDialog && twitchAccountUrl && (
|
||||
<ProviderAccountDialog
|
||||
accountMode={twitchAccountMode}
|
||||
accountUrl={twitchAccountUrl}
|
||||
canEmbedAccount={canEmbedProviderAccount}
|
||||
onAccountModeChange={setTwitchAccountMode}
|
||||
onClose={() => setTwitchAccountDialog(false)}
|
||||
onOpenProvider={openProvider}
|
||||
provider="twitch"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ type EmbedProps = {
|
||||
controllerRef: MutableRefObject<WatchPlaybackController | undefined>;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
youtubeCredentialMode?: 'anonymous' | 'account';
|
||||
twitchCredentialMode?: 'anonymous' | 'account';
|
||||
onReady: () => void;
|
||||
onEnded: () => void;
|
||||
onError: (message: string) => void;
|
||||
@@ -134,6 +136,8 @@ export function WatchEmbedPlayer({
|
||||
controllerRef,
|
||||
volume,
|
||||
muted,
|
||||
youtubeCredentialMode = 'anonymous',
|
||||
twitchCredentialMode = 'anonymous',
|
||||
onReady,
|
||||
onEnded,
|
||||
onError,
|
||||
@@ -170,7 +174,7 @@ export function WatchEmbedPlayer({
|
||||
rel: '0',
|
||||
});
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('credentialless', '');
|
||||
if (youtubeCredentialMode !== 'account') iframe.setAttribute('credentialless', '');
|
||||
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
|
||||
iframe.allowFullscreen = true;
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
@@ -220,6 +224,7 @@ export function WatchEmbedPlayer({
|
||||
parent: [window.location.hostname],
|
||||
autoplay: false,
|
||||
muted,
|
||||
credentialless: twitchCredentialMode !== 'account',
|
||||
};
|
||||
if (source.kind === 'twitch-live') options.channel = source.channel;
|
||||
else options.video = source.videoId;
|
||||
@@ -262,7 +267,7 @@ export function WatchEmbedPlayer({
|
||||
destroy?.();
|
||||
if (controllerRef.current) controllerRef.current = undefined;
|
||||
};
|
||||
}, [controllerRef, source]);
|
||||
}, [controllerRef, source, twitchCredentialMode, youtubeCredentialMode]);
|
||||
|
||||
return <div className="botsu-watch-embed-player" ref={hostRef} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
createJellyfinWatchUrl,
|
||||
createTwitchAccountUrl,
|
||||
createYouTubePremiumUrl,
|
||||
normalizeWatchSourceUrl,
|
||||
parseWatchSource,
|
||||
type WatchSourceDescriptor,
|
||||
@@ -31,6 +33,19 @@ test('recognizes bounded YouTube videos across canonical URL forms', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a safe Premium watch URL at the synchronized position', () => {
|
||||
assert.equal(
|
||||
createYouTubePremiumUrl('dQw4w9WgXcQ', 91.8),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=91s'
|
||||
);
|
||||
assert.equal(
|
||||
createYouTubePremiumUrl('dQw4w9WgXcQ', 0),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||
);
|
||||
assert.throws(() => createYouTubePremiumUrl('../account', 12), /video id/);
|
||||
assert.throws(() => createYouTubePremiumUrl('dQw4w9WgXcQ', Number.NaN), /position/);
|
||||
});
|
||||
|
||||
test('recognizes Twitch live channels and VODs without accepting reserved paths', () => {
|
||||
expectSource('https://www.twitch.tv/chris_live', {
|
||||
kind: 'twitch-live',
|
||||
@@ -46,6 +61,29 @@ test('recognizes Twitch live channels and VODs without accepting reserved paths'
|
||||
});
|
||||
});
|
||||
|
||||
test('creates safe Twitch account URLs for live channels and synchronized VODs', () => {
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-live', channel: 'twitchdev' }, 91.8),
|
||||
'https://www.twitch.tv/twitchdev'
|
||||
);
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-video', videoId: 'v123456789' }, 3_723.9),
|
||||
'https://www.twitch.tv/videos/123456789?t=1h2m3s'
|
||||
);
|
||||
assert.equal(
|
||||
createTwitchAccountUrl({ kind: 'twitch-video', videoId: 'v123456789' }),
|
||||
'https://www.twitch.tv/videos/123456789'
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createTwitchAccountUrl(
|
||||
{ kind: 'twitch-video', videoId: 'v123456789' },
|
||||
Number.POSITIVE_INFINITY
|
||||
),
|
||||
/position/
|
||||
);
|
||||
});
|
||||
|
||||
test('creates and parses a token-free Jellyfin item reference for the fixed friend server', () => {
|
||||
const itemId = 'aea05d9c7069e326d09184d7cb3d2d57';
|
||||
const url = createJellyfinWatchUrl(itemId);
|
||||
|
||||
@@ -75,6 +75,45 @@ export const createJellyfinWatchUrl = (itemId: string): string => {
|
||||
return `${JELLYFIN_WATCH_ORIGIN}/Items/${itemId}`;
|
||||
};
|
||||
|
||||
export const createYouTubePremiumUrl = (videoId: string, positionSeconds = 0): string => {
|
||||
if (!YOUTUBE_VIDEO_ID.test(videoId)) throw new TypeError('YouTube video id is invalid');
|
||||
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
||||
throw new TypeError('YouTube playback position is invalid');
|
||||
}
|
||||
const url = new URL('https://www.youtube.com/watch');
|
||||
url.searchParams.set('v', videoId);
|
||||
const start = Math.floor(positionSeconds);
|
||||
if (start > 0) url.searchParams.set('t', `${start}s`);
|
||||
return url.href;
|
||||
};
|
||||
|
||||
type TwitchWatchSource = Extract<WatchSourceDescriptor, { kind: 'twitch-live' | 'twitch-video' }>;
|
||||
|
||||
const formatTwitchPosition = (positionSeconds: number): string => {
|
||||
const totalSeconds = Math.floor(positionSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours > 0 ? `${hours}h` : ''}${
|
||||
minutes > 0 || hours > 0 ? `${minutes}m` : ''
|
||||
}${seconds}s`;
|
||||
};
|
||||
|
||||
export const createTwitchAccountUrl = (source: TwitchWatchSource, positionSeconds = 0): string => {
|
||||
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
||||
throw new TypeError('Twitch playback position is invalid');
|
||||
}
|
||||
if (source.kind === 'twitch-live') {
|
||||
if (!TWITCH_CHANNEL.test(source.channel)) throw new TypeError('Twitch channel is invalid');
|
||||
return `https://www.twitch.tv/${source.channel}`;
|
||||
}
|
||||
const videoId = source.videoId.startsWith('v') ? source.videoId.slice(1) : '';
|
||||
if (!TWITCH_VIDEO_ID.test(videoId)) throw new TypeError('Twitch video id is invalid');
|
||||
const url = new URL(`https://www.twitch.tv/videos/${videoId}`);
|
||||
if (positionSeconds >= 1) url.searchParams.set('t', formatTwitchPosition(positionSeconds));
|
||||
return url.href;
|
||||
};
|
||||
|
||||
export const parseWatchSource = (value: string): WatchSourceDescriptor => {
|
||||
const url = parseUrl(value);
|
||||
const youtubeVideoId = parseYouTubeVideoId(url);
|
||||
|
||||
@@ -332,6 +332,26 @@
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--youtube svg,
|
||||
.botsu-watch-provider-dialog--youtube svg {
|
||||
color: #ff4040;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--twitch svg,
|
||||
.botsu-watch-provider-dialog--twitch svg {
|
||||
color: #a970ff;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--youtube[aria-pressed='true'] {
|
||||
border-color: #ff5b5b;
|
||||
color: var(--botsu-watch-text);
|
||||
}
|
||||
|
||||
.botsu-watch-account-button--twitch[aria-pressed='true'] {
|
||||
border-color: #a970ff;
|
||||
color: var(--botsu-watch-text);
|
||||
}
|
||||
|
||||
.botsu-watch-play-button:hover:not(:disabled),
|
||||
.botsu-watch-icon-button:hover:not(:disabled),
|
||||
.botsu-watch-button:hover:not(:disabled),
|
||||
@@ -626,6 +646,65 @@
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-dialog {
|
||||
width: min(600px, 100%);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-dialog--twitch {
|
||||
width: min(660px, 100%);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro,
|
||||
.botsu-watch-provider-option {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro strong,
|
||||
.botsu-watch-provider-option strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-intro p,
|
||||
.botsu-watch-provider-option p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--botsu-watch-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-option {
|
||||
padding: 14px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--botsu-watch-border);
|
||||
border-radius: var(--botsu-watch-radius);
|
||||
background: var(--botsu-watch-surface);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-toggle[aria-pressed='true'],
|
||||
.botsu-watch-provider-toggle[aria-pressed='true']:hover:not(:disabled) {
|
||||
border-color: var(--botsu-watch-accent);
|
||||
background: var(--botsu-watch-accent);
|
||||
color: var(--botsu-watch-on-accent);
|
||||
}
|
||||
|
||||
.botsu-watch-provider-note {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.botsu-watch-source-dialog label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -869,6 +948,15 @@
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button {
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.botsu-watch-account-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-chat-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -894,6 +982,15 @@
|
||||
.botsu-watch-source-dialog footer span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-option {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.botsu-watch-provider-actions .botsu-watch-button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const statePath = new URL('../../app/state/callEmbed.ts', import.meta.url);
|
||||
const embedPath = new URL('./WatchEmbedPlayer.tsx', import.meta.url);
|
||||
const jellyfinPath = new URL('./jellyfin.ts', import.meta.url);
|
||||
const twitchSdkPath = new URL('../../../vendor/twitch/embed-v1.js', import.meta.url);
|
||||
const twitchReadmePath = new URL('../../../vendor/twitch/README.md', import.meta.url);
|
||||
|
||||
test('Watch Room follows the Cinny voice-room layout and owns its fullscreen-capable chat panel', async () => {
|
||||
const [watchView, watchCss, room, header, state] = await Promise.all([
|
||||
@@ -70,11 +71,12 @@ test('Watch Room provides an OLED fullscreen player with fading VLC controls and
|
||||
});
|
||||
|
||||
test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin playback', async () => {
|
||||
const [watchView, embed, jellyfin, twitchSdk] = await Promise.all([
|
||||
const [watchView, embed, jellyfin, twitchSdk, twitchReadme] = await Promise.all([
|
||||
readFile(watchViewPath, 'utf8'),
|
||||
readFile(embedPath, 'utf8'),
|
||||
readFile(jellyfinPath, 'utf8'),
|
||||
readFile(twitchSdkPath, 'utf8'),
|
||||
readFile(twitchReadmePath, 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(watchView, /<WatchEmbedPlayer/);
|
||||
@@ -90,10 +92,26 @@ test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin pl
|
||||
assert.doesNotMatch(embed, /script\.crossOrigin = 'anonymous'/);
|
||||
assert.match(embed, /found\?\.remove\(\)/);
|
||||
assert.match(embed, /scripts\.delete\(src\)/);
|
||||
assert.match(embed, /youtubeCredentialMode/);
|
||||
assert.match(embed, /youtubeCredentialMode !== 'account'/);
|
||||
assert.match(embed, /iframe\.setAttribute\('credentialless', ''\)/);
|
||||
assert.match(watchView, /YOUTUBE_ACCOUNT_MODE_KEY/);
|
||||
assert.match(watchView, /Compte YouTube Premium/);
|
||||
assert.match(watchView, /window\.crossOriginIsolated/);
|
||||
assert.match(watchView, /createYouTubePremiumUrl/);
|
||||
assert.match(watchView, /Ce réglage reste sur cet appareil/);
|
||||
assert.match(embed, /\/vendor\/twitch\/embed-v1\.js/);
|
||||
assert.match(embed, /parent: \[window\.location\.hostname\]/);
|
||||
assert.match(embed, /twitchCredentialMode/);
|
||||
assert.match(embed, /credentialless: twitchCredentialMode !== 'account'/);
|
||||
assert.match(twitchSdk, /setAttribute\(['"]credentialless['"], ['"]['"]\)/);
|
||||
assert.match(twitchSdk, /delete playerOptions\.credentialless/);
|
||||
assert.match(twitchSdk, /credentialless &&/);
|
||||
assert.match(twitchReadme, /BOTSU-only `credentialless` player option/);
|
||||
assert.match(watchView, /TWITCH_ACCOUNT_MODE_KEY/);
|
||||
assert.match(watchView, /Compte Twitch/);
|
||||
assert.match(watchView, /createTwitchAccountUrl/);
|
||||
assert.match(watchView, /Aucun cookie, jeton Twitch/);
|
||||
assert.match(jellyfin, /Username: JELLYFIN_USERNAME, Pw: ''/);
|
||||
assert.match(jellyfin, /window\.sessionStorage/);
|
||||
assert.doesNotMatch(watchView, /api_key=/);
|
||||
|
||||
@@ -4,6 +4,6 @@ Source: https://player.twitch.tv/js/embed/v1.js
|
||||
|
||||
Vendored for the BOTSU Watch Room because `test.botsu.net` and `client.botsu.net` run with cross-origin isolation. The upstream SDK response does not expose CORS/CORP headers, so loading it directly is blocked by COEP.
|
||||
|
||||
Local change: the iframe created by the SDK receives the HTML `credentialless` attribute before its `src` is assigned. This preserves cross-origin isolation while allowing Twitch's official player and command API to run.
|
||||
Local change: the SDK accepts a BOTSU-only `credentialless` player option, removes it before building Twitch's query string, and adds the HTML `credentialless` attribute before `src` unless that option is `false`. This preserves cross-origin isolation by default while allowing an explicitly enabled local account mode on pages that are not cross-origin isolated.
|
||||
|
||||
When refreshing the upstream file, reapply that single change and rerun `src/botsu/watch/watch-ui.test.ts` plus the live provider probe.
|
||||
When refreshing the upstream file, reapply that conditional iframe change and rerun `src/botsu/watch/watch-ui.test.ts` plus the live provider probe.
|
||||
|
||||
@@ -603,14 +603,17 @@
|
||||
}
|
||||
var m = r(663);
|
||||
function g(e, t) {
|
||||
const credentialless = false !== e.credentialless,
|
||||
playerOptions = Object.assign({}, e);
|
||||
delete playerOptions.credentialless;
|
||||
const r = `${`https://${t}.twitch.tv`}?${m.stringify(
|
||||
Object.assign(Object.assign({}, e), {
|
||||
Object.assign(Object.assign({}, playerOptions), {
|
||||
parent: b(e.parent),
|
||||
referrer: document.location.href,
|
||||
})
|
||||
)}`,
|
||||
n = document.createElement('iframe');
|
||||
n.setAttribute('credentialless', ''),
|
||||
credentialless && n.setAttribute('credentialless', ''),
|
||||
n.setAttribute('src', r),
|
||||
n.setAttribute('allowfullscreen', ''),
|
||||
n.setAttribute('scrolling', 'no'),
|
||||
|
||||