+285
-183
Diff
round #2
+5
-4
README.md
+5
-4
README.md
···
13
13
| Netflix | `netflix.com/watch/...` |
14
14
| YouTube | `youtube.com/watch?v=...`, `youtu.be/...`, `youtube.com/embed/...`, `youtube.com/live/...` |
15
15
16
-
Adding a service is a self-contained change: drop a `ServicePlugin` under
16
+
Adding a service starts in `packages/shared/src/services.ts`, which owns the
17
+
service ID, display metadata, URL parsing, canonical watch URL builder, and
18
+
extension match patterns. Then add the extension-only DOM integration under
17
19
`apps/extension/src/utils/services/<id>.ts`, add a one-line
18
-
`runServiceContentScript(MY_SERVICE)` entrypoint, register the plugin in
19
-
`SERVICE_PLUGINS`, and append its origin to `host_permissions` in
20
-
`apps/extension/wxt.config.ts`.
20
+
`runServiceContentScript(MY_SERVICE)` entrypoint, and register the plugin in
21
+
`SERVICE_PLUGINS`.
21
22
22
23
## Commands
23
24
+1
-1
apps/extension/src/components/popup/Lobby.svelte
+1
-1
apps/extension/src/components/popup/Lobby.svelte
···
59
59
60
60
<section class="flex flex-col gap-3">
61
61
<div class="flex items-start gap-3">
62
-
<ServiceBadge serviceId={activeDescriptor?.id ?? null} />
62
+
<ServiceBadge serviceId={activeTab.activeServiceId} />
63
63
<div class="min-w-0 space-y-1">
64
64
<p class="m-0 text-sm font-semibold leading-5">
65
65
{title}
+1
-2
apps/extension/src/entrypoints/netflix.content.ts
+1
-2
apps/extension/src/entrypoints/netflix.content.ts
+1
-2
apps/extension/src/entrypoints/youtube.content.ts
+1
-2
apps/extension/src/entrypoints/youtube.content.ts
+1
-1
apps/extension/src/utils/active-tab.ts
+1
-1
apps/extension/src/utils/active-tab.ts
+6
-3
apps/extension/src/utils/background/controlled-tab-service.ts
+6
-3
apps/extension/src/utils/background/controlled-tab-service.ts
···
19
19
playback: PlaybackUpdateDraft;
20
20
}
21
21
22
+
function isPluginUrl(plugin: { matchesUrl(url: URL): boolean }, rawUrl: string): boolean {
23
+
return URL.canParse(rawUrl) && plugin.matchesUrl(new URL(rawUrl));
24
+
}
25
+
22
26
export class ControlledTabService {
23
27
constructor(
24
28
private readonly state: BackgroundState,
···
35
39
if (tabId === controlledTab?.tabId && tab.url) {
36
40
const session = selectSession(this.state);
37
41
const sessionPlugin = session ? getPlugin(session.serviceId) : null;
38
-
if (sessionPlugin && !sessionPlugin.parseUrl(tab.url)) {
42
+
if (sessionPlugin && !isPluginUrl(sessionPlugin, tab.url)) {
39
43
this.state.lastWarning = `The controlled tab left ${sessionPlugin.descriptor.label}.`;
40
44
syncBackgroundState(this.state);
41
45
}
···
164
168
throw new Error('This tab is not on a supported streaming service.');
165
169
}
166
170
167
-
const parsedContextUrl = plugin.parseUrl(context.href);
168
-
if (!parsedContextUrl?.mediaId) {
171
+
if (plugin.extractMediaId(new URL(context.href)) === null) {
169
172
throw new Error(`${plugin.descriptor.label} tab is not on a supported watch page.`);
170
173
}
171
174
+11
-10
apps/extension/src/utils/services/dom-video.ts
+11
-10
apps/extension/src/utils/services/dom-video.ts
···
1
-
import type { PlaybackUpdateDraft } from '@open-watch-party/shared';
1
+
import type { PlaybackUpdateDraft, ServiceId } from '@open-watch-party/shared';
2
2
import { defineContentScript } from 'wxt/utils/define-content-script';
3
3
4
4
import type { ServiceContentContext } from '../protocol/extension';
5
5
import { onMessage, sendMessage } from '../protocol/messaging';
6
-
import type { ServicePlugin } from './types';
6
+
import { SERVICE_PLUGIN_BY_ID } from './registry';
7
7
8
8
const VIDEO_EVENTS = ['play', 'pause', 'seeked', 'loadedmetadata', 'ended'] as const;
9
9
const SEEK_THRESHOLD_SEC = 1.5;
10
10
11
-
export function runServiceContentScript(plugin: ServicePlugin) {
11
+
export function runServiceContentScript(serviceId: ServiceId) {
12
+
const plugin = SERVICE_PLUGIN_BY_ID[serviceId];
13
+
12
14
return defineContentScript({
13
15
matches: [...plugin.contentMatches],
14
16
main() {
···
18
20
let refreshFrame: number | null = null;
19
21
let stopped = false;
20
22
21
-
22
23
const readContext = (): ServiceContentContext | null => {
23
-
const mediaId = plugin.parseUrl(window.location.href)?.mediaId;
24
-
if (!activeVideo || !mediaId) return null;
24
+
const mediaId = plugin.extractMediaId(new URL(window.location.href));
25
+
if (!activeVideo || mediaId === null) return null;
25
26
26
27
return {
27
-
serviceId: plugin.id,
28
+
serviceId,
28
29
href: window.location.href,
29
30
title: document.title,
30
31
mediaTitle: plugin.getMediaTitle(),
···
33
34
};
34
35
35
36
const readPlayback = (): PlaybackUpdateDraft | null => {
36
-
const mediaId = plugin.parseUrl(window.location.href)?.mediaId;
37
-
if (!activeVideo || !mediaId) return null;
37
+
const mediaId = plugin.extractMediaId(new URL(window.location.href));
38
+
if (!activeVideo || mediaId === null) return null;
38
39
39
40
return {
40
-
serviceId: plugin.id,
41
+
serviceId,
41
42
mediaId,
42
43
title: plugin.getMediaTitle(),
43
44
positionSec: Number(activeVideo.currentTime.toFixed(3)),
+3
-28
apps/extension/src/utils/services/netflix.ts
+3
-28
apps/extension/src/utils/services/netflix.ts
···
1
+
import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared';
1
2
import type { ServicePlugin } from './types';
2
3
3
-
const NETFLIX_HOST_RE = /(^|\.)netflix\.com$/;
4
4
const NETFLIX_TITLE_SUFFIX = /\s*-\s*Netflix$/i;
5
-
6
-
function parseNetflix(rawUrl: string): URL | null {
7
-
try {
8
-
const url = new URL(rawUrl);
9
-
return NETFLIX_HOST_RE.test(url.hostname) ? url : null;
10
-
} catch {
11
-
return null;
12
-
}
13
-
}
14
-
15
-
function extractNetflixMediaId(url: URL): string | undefined {
16
-
return url.pathname.match(/^\/watch\/(\d+)/)?.[1];
17
-
}
5
+
const NETFLIX_DEFINITION = SERVICE_DEFINITION_BY_ID.netflix;
18
6
19
7
export const NETFLIX_SERVICE: ServicePlugin = {
20
-
id: 'netflix',
21
-
descriptor: {
22
-
id: 'netflix',
23
-
label: 'Netflix',
24
-
accent: '#e50914',
25
-
accentContrast: '#ffffff',
26
-
glyph: 'N',
27
-
watchPathHint: 'netflix.com/watch/…',
28
-
},
29
-
contentMatches: ['*://*.netflix.com/*'],
8
+
...NETFLIX_DEFINITION,
30
9
playerNotReadyMessage: 'Netflix player is still loading.',
31
-
parseUrl: (url) => {
32
-
const parsed = parseNetflix(url);
33
-
return parsed ? { mediaId: extractNetflixMediaId(parsed) } : null;
34
-
},
35
10
getVideo: () => document.querySelector<HTMLVideoElement>('video'),
36
11
getMediaTitle: () => document.title.replace(NETFLIX_TITLE_SUFFIX, '').trim() || 'Netflix',
37
12
};
+35
-20
apps/extension/src/utils/services/registry.ts
+35
-20
apps/extension/src/utils/services/registry.ts
···
1
-
import type { ServiceId } from '@open-watch-party/shared';
1
+
import { findServiceDefinitionByUrl, type ServiceId } from '@open-watch-party/shared';
2
2
3
3
import { NETFLIX_SERVICE } from './netflix';
4
4
import { YOUTUBE_SERVICE } from './youtube';
5
-
import type { ServiceDescriptor, ServicePlugin } from './types';
5
+
import type { ServicePlugin } from './types';
6
6
7
7
/**
8
8
* Every service the extension knows about. Order drives popup rendering.
9
9
*
10
10
* Adding a service:
11
-
* 1. Add a `ServiceId` to `packages/shared/src/protocol.ts`.
12
-
* 2. Create a `ServicePlugin` at `apps/extension/src/utils/services/<id>.ts`.
11
+
* 1. Add its shared definition to `packages/shared/src/services.ts`.
12
+
* 2. Create the extension DOM integration at `apps/extension/src/utils/services/<id>.ts`.
13
13
* 3. Add a one-line entrypoint at `src/entrypoints/<id>.content.ts` via
14
-
* `runServiceContentScript(MY_SERVICE)`.
15
-
* 4. Append the plugin below and add its origin(s) to `host_permissions`
16
-
* in `wxt.config.ts`.
14
+
* `runServiceContentScript('my-service-id')`.
15
+
* 4. Append the plugin below.
17
16
*/
18
-
export const SERVICE_PLUGINS: readonly ServicePlugin[] = [NETFLIX_SERVICE, YOUTUBE_SERVICE];
17
+
export const SERVICE_PLUGIN_BY_ID = {
18
+
netflix: NETFLIX_SERVICE,
19
+
youtube: YOUTUBE_SERVICE,
20
+
} satisfies Record<ServiceId, ServicePlugin>;
19
21
20
-
export const SUPPORTED_SERVICE_DESCRIPTORS: readonly ServiceDescriptor[] = SERVICE_PLUGINS.map(
21
-
(p) => p.descriptor,
22
-
);
22
+
export const SERVICE_PLUGINS = Object.values(SERVICE_PLUGIN_BY_ID);
23
+
24
+
type ServicePluginDescriptor = ServicePlugin['descriptor'];
25
+
26
+
export const SUPPORTED_SERVICE_DESCRIPTORS: readonly ServicePluginDescriptor[] =
27
+
SERVICE_PLUGINS.map((p) => p.descriptor);
23
28
24
29
export function getPlugin(id: ServiceId | null | undefined): ServicePlugin | null {
25
-
return SERVICE_PLUGINS.find((p) => p.id === id) ?? null;
30
+
return id ? SERVICE_PLUGIN_BY_ID[id] : null;
26
31
}
27
32
28
-
export function getServiceDescriptor(id: ServiceId | null | undefined): ServiceDescriptor | null {
33
+
export function getServiceDescriptor(
34
+
id: ServiceId | null | undefined,
35
+
): ServicePluginDescriptor | null {
29
36
return getPlugin(id)?.descriptor ?? null;
30
37
}
31
38
···
35
42
*/
36
43
export function findPluginByUrl(
37
44
url: string | null | undefined,
38
-
): { plugin: ServicePlugin; isWatchPage: boolean } | null {
45
+
): { serviceId: ServiceId; plugin: ServicePlugin; isWatchPage: boolean } | null {
39
46
if (!url) return null;
40
-
for (const plugin of SERVICE_PLUGINS) {
41
-
const parsedUrl = plugin.parseUrl(url);
42
-
if (parsedUrl) {
43
-
return { plugin, isWatchPage: Boolean(parsedUrl.mediaId) };
44
-
}
47
+
48
+
let parsedUrl: URL;
49
+
try {
50
+
parsedUrl = new URL(url);
51
+
} catch {
52
+
return null;
45
53
}
46
-
return null;
54
+
55
+
const serviceMatch = findServiceDefinitionByUrl(parsedUrl);
56
+
if (!serviceMatch) return null;
57
+
58
+
const plugin = getPlugin(serviceMatch.serviceId);
59
+
return plugin
60
+
? { serviceId: serviceMatch.serviceId, plugin, isWatchPage: serviceMatch.isWatchPage }
61
+
: null;
47
62
}
+4
-25
apps/extension/src/utils/services/types.ts
+4
-25
apps/extension/src/utils/services/types.ts
···
1
-
import type { ServiceId } from '@open-watch-party/shared';
2
-
3
-
/** Presentation metadata rendered by the popup UI. */
4
-
export interface ServiceDescriptor {
5
-
readonly id: ServiceId;
6
-
readonly label: string;
7
-
readonly accent: string;
8
-
readonly accentContrast: string;
9
-
readonly glyph: string;
10
-
readonly watchPathHint: string;
11
-
}
1
+
import type { ServiceDefinition } from '@open-watch-party/shared';
12
2
13
3
/**
14
-
* Self-contained service integration. A plugin bundles the service metadata,
15
-
* URL classifier, DOM selectors, and the one optional playback override needed
16
-
* to drive a service from a content script.
4
+
* Service integration plus the extension-only DOM hooks needed by a content script.
17
5
*/
18
-
export interface ServicePlugin {
19
-
readonly id: ServiceId;
20
-
readonly descriptor: ServiceDescriptor;
21
-
/** WXT-style match patterns; consumed at build time for the manifest. */
22
-
readonly contentMatches: readonly string[];
6
+
export type ServicePlugin = ServiceDefinition & {
23
7
/** Shown when a watch URL matched but the `<video>` element isn't ready. */
24
8
readonly playerNotReadyMessage: string;
25
-
/**
26
-
* Returns null for URLs outside the service. A non-null result without
27
-
* `mediaId` means the URL belongs to the service but is not a watch page.
28
-
*/
29
-
parseUrl(url: string): { mediaId?: string } | null;
30
9
getVideo(): HTMLVideoElement | null;
31
10
getMediaTitle(): string;
32
-
}
11
+
};
+3
-46
apps/extension/src/utils/services/youtube.ts
+3
-46
apps/extension/src/utils/services/youtube.ts
···
1
+
import { SERVICE_DEFINITION_BY_ID } from '@open-watch-party/shared';
1
2
import type { ServicePlugin } from './types';
2
3
3
-
const YOUTUBE_HOST_RE = /(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/;
4
4
const YOUTUBE_TITLE_SUFFIX = /\s*-\s*YouTube$/i;
5
-
6
-
function parseYoutube(rawUrl: string): URL | null {
7
-
try {
8
-
const url = new URL(rawUrl);
9
-
return YOUTUBE_HOST_RE.test(url.hostname) ? url : null;
10
-
} catch {
11
-
return null;
12
-
}
13
-
}
14
-
15
-
function extractYoutubeMediaId(url: URL): string | undefined {
16
-
const host = url.hostname;
17
-
18
-
if (/(^|\.)youtube\.com$/.test(host)) {
19
-
if (url.pathname === '/watch') {
20
-
return url.searchParams.get('v') ?? undefined;
21
-
}
22
-
return url.pathname.match(/^\/(?:embed|live)\/([^/?#]+)/)?.[1] ?? undefined;
23
-
}
24
-
25
-
if (/(^|\.)youtube-nocookie\.com$/.test(host)) {
26
-
return url.pathname.match(/^\/embed\/([^/?#]+)/)?.[1] ?? undefined;
27
-
}
28
-
29
-
if (host === 'youtu.be') {
30
-
const id = url.pathname.replace(/^\//, '').split('/')[0];
31
-
return id || undefined;
32
-
}
33
-
34
-
return undefined;
35
-
}
5
+
const YOUTUBE_DEFINITION = SERVICE_DEFINITION_BY_ID.youtube;
36
6
37
7
export const YOUTUBE_SERVICE: ServicePlugin = {
38
-
id: 'youtube',
39
-
descriptor: {
40
-
id: 'youtube',
41
-
label: 'YouTube',
42
-
accent: '#ff0033',
43
-
accentContrast: '#ffffff',
44
-
glyph: 'Y',
45
-
watchPathHint: 'youtube.com/watch?v=…',
46
-
},
47
-
contentMatches: ['*://*.youtube.com/*', '*://youtu.be/*', '*://*.youtube-nocookie.com/*'],
8
+
...YOUTUBE_DEFINITION,
48
9
playerNotReadyMessage: 'YouTube player is still loading.',
49
-
parseUrl: (url) => {
50
-
const parsed = parseYoutube(url);
51
-
return parsed ? { mediaId: extractYoutubeMediaId(parsed) } : null;
52
-
},
53
10
getVideo: () =>
54
11
document.querySelector<HTMLVideoElement>('#movie_player video, video.html5-main-video, video'),
55
12
getMediaTitle: () => document.title.replace(YOUTUBE_TITLE_SUFFIX, '').trim(),
+2
-6
apps/extension/wxt.config.ts
+2
-6
apps/extension/wxt.config.ts
···
1
1
import { defineConfig } from 'wxt';
2
2
import tailwindcss from '@tailwindcss/vite';
3
+
import { SUPPORTED_SERVICE_CONTENT_MATCHES } from '@open-watch-party/shared';
3
4
4
5
const LOCAL_SERVER_URL = 'http://localhost:8787';
5
6
···
19
20
'wss://*',
20
21
];
21
22
22
-
const hostPermissions = [
23
-
'*://*.netflix.com/*',
24
-
'*://*.youtube.com/*',
25
-
'*://youtu.be/*',
26
-
'*://*.youtube-nocookie.com/*',
27
-
];
23
+
const hostPermissions = [...SUPPORTED_SERVICE_CONTENT_MATCHES];
28
24
29
25
export default defineConfig({
30
26
srcDir: 'src',
+2
-1
apps/server/src/socket.ts
+2
-1
apps/server/src/socket.ts
···
507
507
const key = memberKey(roomCode, memberId);
508
508
const priorSocketId = state.activeSocketByMember.get(key);
509
509
const allowPlaybackUpdate =
510
-
state.sessionsBySocket.get(socketId)?.allowPlaybackUpdate ?? createPlaybackUpdateTokenConsumer();
510
+
state.sessionsBySocket.get(socketId)?.allowPlaybackUpdate ??
511
+
createPlaybackUpdateTokenConsumer();
511
512
512
513
if (priorSocketId && priorSocketId !== socketId) {
513
514
log.info(
History
3 rounds
0 comments
ruszabarov.tngl.sh
submitted
#2
1 commit
expand
collapse
centralize service definition
merge conflicts detected
expand
collapse
expand
collapse
- README.md:13
- apps/extension/src/components/popup/Lobby.svelte:59
- apps/extension/src/entrypoints/netflix.content.ts:1
- apps/extension/src/entrypoints/youtube.content.ts:1
- apps/extension/src/utils/active-tab.ts:40
- apps/extension/src/utils/background/controlled-tab-service.ts:19
- apps/extension/src/utils/services/dom-video.ts:1
- apps/extension/src/utils/services/netflix.ts:1
- apps/extension/src/utils/services/registry.ts:1
- apps/extension/src/utils/services/types.ts:1
- apps/extension/src/utils/services/youtube.ts:1
- apps/extension/wxt.config.ts:1
- apps/server/src/socket.ts:507
- packages/shared/src/protocol.ts:1
- packages/shared/src/room.test.ts:1
- packages/shared/src/room.ts:11
- packages/shared/src/services.ts:1
expand 0 comments
ruszabarov.tngl.sh
submitted
#1
1 commit
expand
collapse
centralize service definition
expand 0 comments
ruszabarov.tngl.sh
submitted
#0
1 commit
expand
collapse
centralize service definition