41 changed files with 1186 additions and 213 deletions
+17 -11
View File
@@ -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">
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -7,7 +7,13 @@ import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
export function CallChatView({ onClose }: { onClose?: () => void }) {
type CallChatViewProps = {
onClose?: () => void;
title?: string;
subtitle?: string;
};
export function CallChatView({ onClose, title = 'Chat', subtitle }: CallChatViewProps) {
const { eventId } = useParams();
const setChat = useSetAtom(callChatAtom);
const screenSize = useScreenSizeContext();
@@ -24,10 +30,15 @@ export function CallChatView({ onClose }: { onClose?: () => void }) {
>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes">
<Box grow="Yes" direction="Column">
<Text size="H5" truncate>
Chat
{title}
</Text>
{subtitle && (
<Text size="T200" priority="300" truncate>
{subtitle}
</Text>
)}
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
+26 -25
View File
@@ -1,9 +1,9 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import type { Room as MatrixRoom } from 'matrix-js-sdk/lib/models/room';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
@@ -96,13 +96,17 @@ export function Room() {
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const [drawingChat, setDrawingChat] = useAtom(drawingChatAtom);
const [watchChat, setWatchChat] = useAtom(watchChatAtom);
const watchChat = useAtomValue(watchChatAtom);
const setWatchChat = useSetAtom(watchChatAtom);
const watchChatRoomRef = useRef<string>();
useEffect(() => {
setDrawingChat(false);
}, [room.roomId, setDrawingChat]);
useEffect(() => {
if (watchChatRoomRef.current === room.roomId) return;
watchChatRoomRef.current = room.roomId;
setWatchChat(false);
}, [room.roomId, setWatchChat]);
@@ -144,13 +148,13 @@ export function Room() {
!watchView &&
!storyboardView &&
!kanbanView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
<RoomView eventId={eventId} />
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
<RoomView eventId={eventId} />
</Box>
</Box>
</Box>
)}
)}
{!callView && documentView && (
<DocumentEditorProvider>
<Box grow="Yes" direction="Column">
@@ -163,27 +167,24 @@ export function Room() {
</DocumentEditorProvider>
)}
{!callView && watchView && (
<Box
grow="Yes"
direction="Column"
style={
screenSize === ScreenSize.Desktop || !watchChat ? undefined : { display: 'none' }
}
>
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes" style={{ minHeight: 0 }}>
<BotsuWatchRoomView room={room} />
<BotsuWatchRoomView
chatOpen={watchChat}
chatContent={
<CallChatView
onClose={() => setWatchChat(false)}
subtitle="Watch Room"
title="Discussion"
/>
}
onChatOpenChange={setWatchChat}
room={room}
/>
</Box>
</Box>
)}
{!callView && watchView && watchChat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView onClose={() => setWatchChat(false)} />
</>
)}
{!callView && storyboardView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
@@ -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');
+540 -101
View File
@@ -1,6 +1,7 @@
import React, {
CSSProperties,
FormEventHandler,
type ReactNode,
useCallback,
useEffect,
useMemo,
@@ -11,9 +12,13 @@ import Hls from 'hls.js';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { color, config } from 'folds';
import {
ArrowClockwiseIcon,
ArrowCounterClockwiseIcon,
ArrowsClockwiseIcon,
ChatCircleIcon,
CheckCircleIcon,
ClosedCaptioningIcon,
CornersInIcon,
CornersOutIcon,
FilmSlateIcon,
LinkIcon,
@@ -21,8 +26,10 @@ import {
PlayIcon,
SpeakerHighIcon,
SpeakerSlashIcon,
TwitchLogoIcon,
UsersThreeIcon,
XIcon,
YoutubeLogoIcon,
} from '@phosphor-icons/react';
import {
createEmptyBotsuWatchSession,
@@ -40,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';
@@ -63,6 +76,9 @@ 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();
const watchThemeStyle = {
@@ -99,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)) {
@@ -372,11 +404,174 @@ function SourceDialog({
);
}
export function BotsuWatchRoomView({ room }: { room: Room }) {
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 laccè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:
'Lisolation de cette version de BOTSU empêche un lecteur connecté. YouTube peut souvrir à 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 nest 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 laccè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:
'Lisolation de cette version de BOTSU empêche un lecteur connecté. Twitch peut souvrir 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 nest 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,
chatOpen,
onChatOpenChange,
}: {
room: Room;
chatContent?: ReactNode;
chatOpen: boolean;
onChatOpenChange: (open: boolean) => void;
}) {
const mx = useMatrixClient();
const roomRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const playbackRef = useRef<WatchPlaybackController>();
const stageRef = useRef<HTMLDivElement>(null);
const controlsIdleTimerRef = useRef<number>();
const hlsRef = useRef<Hls>();
const jellyfinClient = useMemo(createBrowserJellyfinClient, []);
const [watchState, setWatchState] = useState<WatchRoomState>(() => {
@@ -395,6 +590,10 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
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);
@@ -403,6 +602,8 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
const [jellyfinSubtitleIndex, setJellyfinSubtitleIndex] = useState(-1);
const [jellyfinSubtitleUrl, setJellyfinSubtitleUrl] = useState<string>();
const [jellyfinTrackBusy, setJellyfinTrackBusy] = useState(false);
const [immersive, setImmersive] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const session = watchState.snapshot.session;
const source = session.source;
@@ -428,6 +629,62 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
: 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;
window.clearTimeout(controlsIdleTimerRef.current);
controlsIdleTimerRef.current = undefined;
}, []);
const scheduleControlsHide = useCallback(() => {
clearControlsIdleTimer();
if (!immersive || sourceDialog || accountDialogOpen) return;
controlsIdleTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
controlsIdleTimerRef.current = undefined;
}, WATCH_CONTROLS_IDLE_MS);
}, [accountDialogOpen, clearControlsIdleTimer, immersive, sourceDialog]);
const revealControls = useCallback(() => {
setControlsVisible(true);
scheduleControlsHide();
}, [scheduleControlsHide]);
const handleFullscreenToggle = useCallback(async () => {
const watchRoom = roomRef.current;
if (!watchRoom) return;
if (document.fullscreenElement === watchRoom) {
await document.exitFullscreen();
return;
}
if (document.fullscreenElement) await document.exitFullscreen();
await watchRoom.requestFullscreen();
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
const nextImmersive = document.fullscreenElement === roomRef.current;
setImmersive(nextImmersive);
setControlsVisible(true);
if (!nextImmersive) clearControlsIdleTimer();
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [clearControlsIdleTimer]);
useEffect(() => {
if (!immersive) {
setControlsVisible(true);
clearControlsIdleTimer();
return undefined;
}
revealControls();
return clearControlsIdleTimer;
}, [clearControlsIdleTimer, immersive, revealControls]);
const applyPlaybackState = useCallback(async (snapshot: BotsuWatchSnapshot) => {
const player = playbackRef.current;
@@ -642,6 +899,29 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
}
}, [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);
@@ -689,6 +969,20 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
}
};
const handleSeekBy = async (offsetSeconds: number) => {
const player = playbackRef.current;
if (!player?.ready() || !source || !canSeek || sending) return;
const nextPosition = Math.min(Math.max(player.currentTime() + offsetSeconds, 0), duration);
player.seek(nextPosition);
setPosition(nextPosition);
setSeekDraft(undefined);
try {
await sendSession({ ...session, positionMs: Math.round(nextPosition * 1_000) });
} catch {
void applyPlaybackState(watchState.snapshot);
}
};
const commitSeek = async () => {
const player = playbackRef.current;
if (!player?.ready() || seekDraft === undefined || !canSeek || sending) return;
@@ -770,20 +1064,75 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
setCaptions(next);
};
const openProvider = useCallback((url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
useEffect(() => {
if (!immersive || sourceDialog || accountDialogOpen) return undefined;
const handleKeyboardShortcut = (event: KeyboardEvent) => {
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === ' ' || event.code === 'Space') {
event.preventDefault();
void handleTogglePlayback();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
void handleSeekBy(-10);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
void handleSeekBy(10);
} else if (event.key.toLowerCase() === 'm') {
setPreferences((current) => ({ ...current, muted: !current.muted }));
} else if (event.key.toLowerCase() === 'c' && !jellyfinPlayback && captionsAvailable) {
handleCaptionsToggle();
} else if (event.key.toLowerCase() === 'f') {
void handleFullscreenToggle();
}
revealControls();
};
window.addEventListener('keydown', handleKeyboardShortcut);
return () => window.removeEventListener('keydown', handleKeyboardShortcut);
});
const handleEnded = () => {
if (session.paused || !Number.isFinite(duration)) return;
void sendSession({ ...session, paused: true, positionMs: Math.round(duration * 1_000) });
};
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;
return (
<div className="botsu-watch-room" style={watchThemeStyle as CSSProperties}>
<main className="botsu-watch-stage" ref={stageRef}>
<div className="botsu-watch-statusbar">
<div
className={`botsu-watch-room${immersive ? ' botsu-watch-room--immersive' : ''}${
immersive && !controlsVisible ? ' botsu-watch-room--controls-hidden' : ''
}${chatOpen ? ' botsu-watch-room--chat-open' : ''}`}
onFocusCapture={revealControls}
onPointerDown={revealControls}
onPointerMove={revealControls}
ref={roomRef}
style={watchThemeStyle as CSSProperties}
>
<main className="botsu-watch-stage">
<div className="botsu-watch-statusbar botsu-watch-immersive-chrome">
<div className="botsu-watch-sync-status">
{sending ? (
<ArrowsClockwiseIcon aria-hidden className="botsu-watch-spin" size={19} />
@@ -800,7 +1149,7 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
</span>
</div>
<button
className="botsu-watch-button"
className="botsu-watch-button botsu-watch-status-source"
onClick={() => setSourceDialog(true)}
type="button"
>
@@ -820,7 +1169,9 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
onError={setMediaError}
onReady={() => void applyPlaybackState(watchState.snapshot)}
source={embeddedSource}
twitchCredentialMode={twitchAccountActive ? 'account' : 'anonymous'}
volume={preferences.volume}
youtubeCredentialMode={youtubeAccountActive ? 'account' : 'anonymous'}
/>
) : (
<video
@@ -854,7 +1205,7 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
)}
</video>
)}
<div className="botsu-watch-screen-title">
<div className="botsu-watch-screen-title botsu-watch-immersive-chrome">
<FilmSlateIcon aria-hidden size={18} />
<span>{source.title}</span>
</div>
@@ -898,89 +1249,140 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
)}
</section>
<div className="botsu-watch-control-dock">
<section
aria-labelledby="botsu-watch-shared-controls"
className="botsu-watch-controls botsu-watch-controls--shared"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-shared-controls">
LECTURE PARTAGÉE
</span>
<div className="botsu-watch-shared-row">
<button
aria-label={
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
}
className="botsu-watch-play-button"
disabled={!source || sending}
onClick={() => void handleTogglePlayback()}
type="button"
>
{session.paused ? (
<PlayIcon aria-hidden size={24} weight="fill" />
) : (
<PauseIcon aria-hidden size={24} weight="fill" />
)}
</button>
<span className="botsu-watch-time">{formatBotsuWatchTime(displayPosition)}</span>
<input
aria-label="Position commune"
disabled={!source || !canSeek || sending}
max={canSeek ? duration : 1}
min={0}
onBlur={() => void commitSeek()}
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
onKeyUp={() => void commitSeek()}
onPointerUp={() => void commitSeek()}
step={0.1}
type="range"
value={canSeek ? Math.min(displayPosition, duration) : 0}
/>
<span className="botsu-watch-time">
<div
aria-label="Commandes du lecteur"
className="botsu-watch-control-dock botsu-watch-immersive-chrome"
role="group"
>
<div className="botsu-watch-progress-row">
<input
aria-label="Position commune"
disabled={!source || !canSeek || sending}
max={canSeek ? duration : 1}
min={0}
onBlur={() => void commitSeek()}
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
onKeyUp={() => void commitSeek()}
onPointerUp={() => void commitSeek()}
step={0.1}
type="range"
value={canSeek ? Math.min(displayPosition, duration) : 0}
/>
</div>
<div className="botsu-watch-player-actions">
<button
aria-label="Reculer de 10 secondes"
className="botsu-watch-icon-button botsu-watch-skip-button"
disabled={!source || !canSeek || sending}
onClick={() => void handleSeekBy(-10)}
type="button"
>
<ArrowCounterClockwiseIcon aria-hidden size={25} />
<span aria-hidden>10</span>
</button>
<button
aria-label={
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
}
className="botsu-watch-play-button"
disabled={!source || sending}
onClick={() => void handleTogglePlayback()}
type="button"
>
{session.paused ? (
<PlayIcon aria-hidden size={25} weight="fill" />
) : (
<PauseIcon aria-hidden size={25} weight="fill" />
)}
</button>
<button
aria-label="Avancer de 10 secondes"
className="botsu-watch-icon-button botsu-watch-skip-button"
disabled={!source || !canSeek || sending}
onClick={() => void handleSeekBy(10)}
type="button"
>
<ArrowClockwiseIcon aria-hidden size={25} />
<span aria-hidden>10</span>
</button>
<span className="botsu-watch-time botsu-watch-time--combined">
{formatBotsuWatchTime(displayPosition)}
<span aria-hidden> / </span>
<span>
{canSeek ? formatBotsuWatchTime(duration) : source ? 'EN DIRECT' : '--:--'}
</span>
</div>
</section>
<span aria-hidden className="botsu-watch-control-divider" />
<section
aria-labelledby="botsu-watch-personal-controls"
className="botsu-watch-controls botsu-watch-controls--personal"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-personal-controls">
APPAREIL
</span>
<div className="botsu-watch-personal-row">
<div className="botsu-watch-volume">
<span aria-hidden className="botsu-watch-action-divider" />
<div className="botsu-watch-volume">
<button
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
className="botsu-watch-icon-button"
onClick={() => setPreferences((current) => ({ ...current, muted: !current.muted }))}
type="button"
>
{preferences.muted || preferences.volume === 0 ? (
<SpeakerSlashIcon aria-hidden size={22} />
) : (
<SpeakerHighIcon aria-hidden size={22} />
)}
</button>
<input
aria-label="Volume personnel"
max={1}
min={0}
onChange={(event) => {
const volume = Number(event.currentTarget.value);
setPreferences({ volume, muted: volume === 0 });
}}
step={0.01}
type="range"
value={preferences.volume}
/>
<output>{Math.round(preferences.volume * 100)}</output>
</div>
<div className="botsu-watch-secondary-actions">
{sourceDescriptor?.kind === 'youtube' && (
<button
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
className="botsu-watch-icon-button"
onClick={() =>
setPreferences((current) => ({ ...current, muted: !current.muted }))
}
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"
>
{preferences.muted || preferences.volume === 0 ? (
<SpeakerSlashIcon aria-hidden size={22} />
) : (
<SpeakerHighIcon aria-hidden size={22} />
)}
<YoutubeLogoIcon aria-hidden size={20} weight="fill" />
<span>Premium</span>
</button>
<input
aria-label="Volume personnel"
max={1}
min={0}
onChange={(event) => {
const volume = Number(event.currentTarget.value);
setPreferences({ volume, muted: volume === 0 ? true : false });
}}
step={0.01}
type="range"
value={preferences.volume}
/>
<output>{Math.round(preferences.volume * 100)}</output>
</div>
)}
{(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'}
aria-pressed={captions}
className="botsu-watch-icon-button"
disabled={!captionsAvailable}
onClick={handleCaptionsToggle}
type="button"
>
<ClosedCaptioningIcon aria-hidden size={22} />
</button>
)}
<label className="botsu-watch-quality">
<span className="botsu-watch-visually-hidden">Qualité personnelle</span>
<select
@@ -1043,32 +1445,47 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
</label>
</>
)}
{!jellyfinPlayback && (
<button
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
aria-pressed={captions}
className="botsu-watch-icon-button"
disabled={!captionsAvailable}
onClick={handleCaptionsToggle}
type="button"
>
<ClosedCaptioningIcon aria-hidden size={22} />
</button>
)}
<button
aria-label="Plein écran"
aria-label={chatOpen ? 'Fermer la discussion' : 'Ouvrir la discussion'}
aria-pressed={chatOpen}
className="botsu-watch-icon-button"
disabled={!source}
onClick={() => void stageRef.current?.requestFullscreen()}
onClick={() => onChatOpenChange(!chatOpen)}
type="button"
>
<CornersOutIcon aria-hidden size={22} />
<ChatCircleIcon aria-hidden size={23} weight={chatOpen ? 'fill' : 'regular'} />
</button>
<button
className="botsu-watch-button botsu-watch-control-source"
onClick={() => setSourceDialog(true)}
type="button"
>
<LinkIcon aria-hidden size={18} />
{source ? 'Changer le flux' : 'Ajouter un flux'}
</button>
<button
aria-label={immersive ? 'Quitter le plein écran' : 'Plein écran'}
className="botsu-watch-icon-button"
disabled={!source}
onClick={() => void handleFullscreenToggle()}
type="button"
>
{immersive ? (
<CornersInIcon aria-hidden size={22} />
) : (
<CornersOutIcon aria-hidden size={22} />
)}
</button>
</div>
</section>
</div>
</div>
</main>
{chatOpen && chatContent && (
<aside aria-label="Discussion de la Watch Room" className="botsu-watch-chat-panel">
{chatContent}
</aside>
)}
{sourceDialog && (
<SourceDialog
busy={sending}
@@ -1079,6 +1496,28 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
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);
+39
View File
@@ -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);
+400 -53
View File
@@ -13,7 +13,8 @@
.botsu-watch-stage {
display: flex;
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 0;
padding: clamp(12px, 2.2vw, 28px);
@@ -176,31 +177,19 @@
font-size: 0.78rem;
}
.botsu-watch-controls {
min-width: 0;
padding: 8px 10px;
}
.botsu-watch-control-dock {
display: grid;
grid-template-columns: minmax(0, 1.35fr) 1px minmax(320px, 1fr);
width: min(100%, 980px);
display: flex;
width: min(100%, 1120px);
margin: 0 auto;
padding: 8px;
align-items: center;
gap: 8px;
padding: 10px 12px;
flex-direction: column;
gap: 7px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface-raised);
box-sizing: border-box;
}
.botsu-watch-control-divider {
width: 1px;
height: 32px;
background: var(--botsu-watch-border);
}
.botsu-watch-eyebrow {
display: block;
margin-bottom: 10px;
@@ -210,42 +199,45 @@
letter-spacing: 0.15em;
}
.botsu-watch-shared-row,
.botsu-watch-personal-row,
.botsu-watch-progress-row,
.botsu-watch-player-actions,
.botsu-watch-secondary-actions,
.botsu-watch-volume {
display: flex;
align-items: center;
}
.botsu-watch-shared-row {
gap: 12px;
min-height: 44px;
.botsu-watch-progress-row {
min-width: 0;
padding: 0 4px;
}
.botsu-watch-personal-row {
flex-wrap: wrap;
gap: 10px;
.botsu-watch-player-actions {
min-width: 0;
min-height: 44px;
gap: 8px;
}
.botsu-watch-personal-row .botsu-watch-quality select {
max-width: 220px;
.botsu-watch-secondary-actions {
min-width: 0;
margin-left: auto;
gap: 8px;
}
.botsu-watch-volume {
min-width: 130px;
flex: 1 1 260px;
gap: 10px;
width: clamp(128px, 14vw, 190px);
gap: 6px;
}
.botsu-watch-volume output {
width: 2.2rem;
width: 1.9rem;
color: var(--botsu-watch-muted);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
.botsu-watch-shared-row input[type='range'],
.botsu-watch-progress-row input[type='range'],
.botsu-watch-volume input[type='range'] {
min-width: 0;
height: 3px;
@@ -254,17 +246,38 @@
cursor: pointer;
}
.botsu-watch-shared-row input[type='range']:disabled {
.botsu-watch-progress-row input[type='range'] {
width: 100%;
}
.botsu-watch-progress-row input[type='range']:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.botsu-watch-time {
min-width: 3.7rem;
flex: 0 0 auto;
color: var(--botsu-watch-muted);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
}
.botsu-watch-time--combined {
min-width: 7.6rem;
color: var(--botsu-watch-text);
}
.botsu-watch-time--combined > span:last-child {
color: var(--botsu-watch-muted);
}
.botsu-watch-action-divider {
width: 1px;
height: 28px;
margin: 0 4px;
flex: 0 0 auto;
background: var(--botsu-watch-border);
}
.botsu-watch-play-button,
@@ -319,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),
@@ -354,14 +387,217 @@
}
.botsu-watch-quality {
margin-left: auto;
min-width: 0;
}
.botsu-watch-quality select {
min-width: 112px;
min-width: 104px;
max-width: 170px;
padding: 0 34px 0 12px;
appearance: auto;
cursor: pointer;
font-size: 0.75rem;
}
.botsu-watch-skip-button {
position: relative;
}
.botsu-watch-skip-button span {
position: absolute;
top: 17px;
left: 0;
width: 100%;
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
text-align: center;
}
.botsu-watch-control-source {
display: none;
}
.botsu-watch-chat-panel {
position: relative;
z-index: 12;
display: flex;
width: min(28.5rem, 38vw);
min-width: 19rem;
height: 100%;
flex: 0 0 auto;
overflow: hidden;
color: #f5f5f5;
background: #050505;
border-left: 1px solid rgb(255 255 255 / 18%);
box-sizing: border-box;
color-scheme: dark;
--bg-surface-low: #050505;
--bg-surface: #080808;
--bg-surface-high: #0d0d0d;
--bg-surface-hover: #151515;
--bg-surface-active: #1d1d1d;
--bg-surface-border: rgb(255 255 255 / 18%);
--tc-surface-high: #fff;
--tc-surface-normal: #f5f5f5;
--tc-surface-low: #a3a3a3;
}
.botsu-watch-chat-panel > * {
width: 100% !important;
min-width: 0;
height: 100%;
background: #050505;
}
.botsu-watch-room--chat-open .botsu-watch-player-actions {
flex-wrap: wrap;
}
.botsu-watch-room--chat-open .botsu-watch-secondary-actions {
width: 100%;
margin-left: 0;
flex-wrap: wrap;
}
.botsu-watch-room--chat-open .botsu-watch-quality {
flex: 1 1 9rem;
}
.botsu-watch-room--chat-open .botsu-watch-quality select {
width: 100%;
max-width: none;
}
.botsu-watch-immersive-chrome {
transition: opacity 180ms ease, transform 180ms ease;
}
.botsu-watch-room:fullscreen {
width: 100vw;
height: 100vh;
color: #f5f5f5;
background: #000;
--botsu-watch-canvas: #000;
--botsu-watch-surface: #050505;
--botsu-watch-surface-raised: #080808;
--botsu-watch-text: #f5f5f5;
--botsu-watch-muted: #a3a3a3;
--botsu-watch-border: rgb(255 255 255 / 22%);
--botsu-watch-accent: #f5f5f5;
--botsu-watch-on-accent: #050505;
--botsu-watch-radius: 2px;
}
.botsu-watch-room:fullscreen .botsu-watch-stage {
position: relative;
padding: 0;
gap: 0;
overflow: hidden;
background: #000;
}
.botsu-watch-room:fullscreen .botsu-watch-screen {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.botsu-watch-room:fullscreen .botsu-watch-statusbar {
position: absolute;
z-index: 6;
top: 22px;
right: 28px;
width: auto;
min-height: 42px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #f5f5f5;
}
.botsu-watch-room:fullscreen .botsu-watch-status-source {
display: none;
}
.botsu-watch-room:fullscreen .botsu-watch-screen-title {
z-index: 6;
top: 24px;
left: 28px;
max-width: min(46%, 38rem);
padding: 0;
border: 0;
background: transparent;
backdrop-filter: none;
font-size: 0.95rem;
font-weight: 600;
}
.botsu-watch-room:fullscreen .botsu-watch-screen-title svg {
display: none;
}
.botsu-watch-room:fullscreen .botsu-watch-control-dock {
position: absolute;
z-index: 7;
right: 0;
bottom: 0;
left: 0;
width: 100%;
margin: 0;
padding: 52px 28px 24px;
gap: 12px;
border: 0;
border-radius: 0;
background: linear-gradient(to top, #000 20%, rgb(0 0 0 / 86%) 62%, transparent 100%);
}
.botsu-watch-room:fullscreen .botsu-watch-player-actions {
gap: 10px;
}
.botsu-watch-room:fullscreen .botsu-watch-play-button {
width: 56px;
min-height: 52px;
border-color: rgb(255 255 255 / 72%);
background: rgb(5 5 5 / 72%);
color: #f5f5f5;
}
.botsu-watch-room:fullscreen .botsu-watch-icon-button,
.botsu-watch-room:fullscreen .botsu-watch-button,
.botsu-watch-room:fullscreen .botsu-watch-quality select {
border-color: transparent;
background: transparent;
color: #f5f5f5;
}
.botsu-watch-room:fullscreen .botsu-watch-control-source {
display: inline-flex;
border-left-color: rgb(255 255 255 / 20%);
}
.botsu-watch-room--controls-hidden {
cursor: none;
}
.botsu-watch-room--controls-hidden .botsu-watch-immersive-chrome {
opacity: 0;
pointer-events: none;
}
.botsu-watch-room--controls-hidden .botsu-watch-statusbar,
.botsu-watch-room--controls-hidden .botsu-watch-screen-title {
transform: translateY(-8px);
}
.botsu-watch-room--controls-hidden .botsu-watch-control-dock {
transform: translateY(10px);
}
.botsu-watch-source-overlay {
@@ -410,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;
@@ -568,24 +863,46 @@
}
}
@media (max-width: 1280px) {
.botsu-watch-room:fullscreen .botsu-watch-player-actions {
flex-wrap: wrap;
}
.botsu-watch-room:fullscreen .botsu-watch-secondary-actions {
width: 100%;
margin-left: 0;
flex-wrap: wrap;
}
}
@media (max-width: 960px) {
.botsu-watch-stage {
overflow: auto;
}
.botsu-watch-room:fullscreen .botsu-watch-stage {
overflow: hidden;
}
.botsu-watch-screen {
flex: 0 0 auto;
min-height: 240px;
aspect-ratio: 16 / 9;
}
.botsu-watch-control-dock {
grid-template-columns: minmax(0, 1fr);
.botsu-watch-player-actions {
flex-wrap: wrap;
}
.botsu-watch-control-divider {
.botsu-watch-secondary-actions {
width: 100%;
height: 1px;
margin-left: 0;
flex-wrap: wrap;
}
.botsu-watch-room:fullscreen .botsu-watch-control-dock {
padding-right: 18px;
padding-left: 18px;
}
}
@@ -606,28 +923,47 @@
min-height: 210px;
}
.botsu-watch-shared-row {
display: grid;
grid-template-columns: 48px 54px minmax(90px, 1fr) 58px;
gap: 8px;
.botsu-watch-player-actions {
gap: 6px;
}
.botsu-watch-play-button {
width: 48px;
}
.botsu-watch-personal-row {
flex-wrap: wrap;
.botsu-watch-time--combined {
min-width: 6.8rem;
font-size: 0.72rem;
}
.botsu-watch-volume {
width: 100%;
flex-basis: 100%;
width: min(100%, 180px);
}
.botsu-watch-quality {
margin-left: 0;
margin-right: auto;
max-width: 150px;
}
.botsu-watch-control-source {
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;
}
.botsu-watch-room--chat-open .botsu-watch-stage {
display: none;
}
.botsu-watch-source-overlay {
@@ -646,10 +982,21 @@
.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) {
.botsu-watch-spin {
.botsu-watch-spin,
.botsu-watch-immersive-chrome {
animation: none;
transition: none;
}
}
+54 -4
View File
@@ -10,8 +10,9 @@ 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 opens chat in the shared side panel', async () => {
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([
readFile(watchViewPath, 'utf8'),
readFile(watchCssPath, 'utf8'),
@@ -25,8 +26,12 @@ test('Watch Room follows the Cinny voice-room layout and opens chat in the share
assert.doesNotMatch(watchView, /ChatsCircleIcon/);
assert.match(state, /watchChatAtom = atom<boolean>\(false\)/);
assert.match(room, /watchView && watchChat/);
assert.match(room, /<CallChatView onClose=\{\(\) => setWatchChat\(false\)\}/);
assert.match(watchView, /chatContent/);
assert.doesNotMatch(watchView, /CallChatView/);
assert.match(room, /chatContent=\{/);
assert.match(room, /title="Discussion"/);
assert.match(room, /subtitle="Watch Room"/);
assert.doesNotMatch(room, /watchView && watchChat/);
assert.match(header, /function WatchChatButton/);
assert.match(header, /isWatchRoom\(room\) && <WatchChatButton \/>/);
assert.match(header, /Icons\.Message/);
@@ -37,12 +42,41 @@ test('Watch Room follows the Cinny voice-room layout and opens chat in the share
assert.doesNotMatch(watchCss, /grid-template-columns: minmax\(0, 1fr\) minmax\(320px, 380px\)/);
});
test('Watch Room provides an OLED fullscreen player with fading VLC controls and inline chat', async () => {
const [watchView, watchCss, room] = await Promise.all([
readFile(watchViewPath, 'utf8'),
readFile(watchCssPath, 'utf8'),
readFile(roomPath, 'utf8'),
]);
assert.match(watchView, /const WATCH_CONTROLS_IDLE_MS = 2_800/);
assert.match(watchView, /document\.fullscreenElement === roomRef\.current/);
assert.match(watchView, /document\.exitFullscreen\(\)/);
assert.match(watchView, /setControlsVisible\(false\)/);
assert.match(watchView, /onPointerMove=\{revealControls\}/);
assert.match(watchView, /<ArrowCounterClockwiseIcon/);
assert.match(watchView, /<ArrowClockwiseIcon/);
assert.match(watchView, /<ChatCircleIcon/);
assert.match(watchView, /<CornersInIcon/);
assert.match(watchView, /chatOpen && chatContent/);
assert.match(room, /<CallChatView/);
assert.match(watchView, /chatOpen \? 'Fermer la discussion' : 'Ouvrir la discussion'/);
assert.doesNotMatch(room, /!callView && watchView && watchChat/);
assert.match(watchCss, /\.botsu-watch-room:fullscreen/);
assert.match(watchCss, /\.botsu-watch-room--controls-hidden/);
assert.match(watchCss, /cursor: none/);
assert.match(watchCss, /\.botsu-watch-chat-panel/);
assert.match(watchCss, /background: #000/);
});
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/);
@@ -58,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=/);
+2 -2
View File
@@ -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.
+5 -2
View File
@@ -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'),