Skip to content

Commit 9384cc2

Browse files
committed
refactor(youtube): decompose monolithic adapter into parser, enforcer, and adapter modules
- extract pure URL parsing functions (isYouTubeUrl, getVideoId, getPlatformType) into parser.ts to enable isolated unit testing without browser context - extract DOM enforcement logic (MutationObserver, video silencing, keyboard blocking) into YouTubeDomEnforcer class in enforcer.ts - simplify YouTubeAdapter to coordinate only: remove storage dependency, storageListener duplication, and private getVideoId in favor of parser imports - update content.ts import path and constructor signature accordingly - migrate tests/platforms/youtube.test.ts to tests/platforms/youtube/adapter.test.ts - add tests/platforms/youtube/parser.test.ts covering all pure functions
1 parent 4124415 commit 9384cc2

6 files changed

Lines changed: 174 additions & 144 deletions

File tree

src/content.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { YouTubeAdapter } from './platforms/youtube/index';
1+
import { YouTubeAdapter } from './platforms/youtube/adapter';
22
import { AppOrchestrator } from './app/orchestrator';
33
import { ChromeMessageBus } from './adapters/chrome/message-bus';
44
import { ChromeConnectionManager } from './adapters/chrome/connection-manager';
@@ -45,7 +45,7 @@ if (!window.__LIMITRA_INJECTED__) {
4545
const currentUrl = location.href;
4646

4747
const adapters = [
48-
new YouTubeAdapter(storage, currentUrl, () => {
48+
new YouTubeAdapter(currentUrl, () => {
4949
void bootstrap();
5050
}),
5151
];
Lines changed: 15 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,37 @@
11
import { PlatformAdapter, ItemChangeCallback } from '../../core/interfaces/platform-adapter';
22
import { PlatformId } from '../../types';
3-
import { StorageChangeListener } from '../../core/storage/driver';
4-
import { StorageFacade } from '../../core/storage/index';
3+
import { isYouTubeUrl, getVideoId, getPlatformType } from './parser';
4+
import { YouTubeDomEnforcer } from './enforcer';
55

66
export class YouTubeAdapter implements PlatformAdapter {
77
public readonly id: PlatformId;
88
public name: string;
99

10-
// Dynamic threshold: 1.5s for Shorts, 10s for regular Watch videos
1110
private get WATCH_THRESHOLD() {
1211
return this.id === 'youtube_shorts' ? 1500 : 10000;
1312
}
1413

15-
private readonly BLOCKED_KEYS = [' ', 'k', 'ArrowUp', 'ArrowDown'];
16-
17-
private storageListener: StorageChangeListener | null = null;
1814
private callback: ItemChangeCallback | null = null;
19-
2015
private lastVideoId: string | null = null;
2116
private pendingVideoId: string | null = null;
2217

23-
private punishmentObserver: MutationObserver | null = null;
24-
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
25-
2618
private ytNavigationHandler: ((e: Event) => void) | null = null;
2719
private popstateHandler: (() => void) | null = null;
20+
private visibilityHandler: (() => void) | null = null;
2821
private urlCheckInterval: number | null = null;
2922

3023
private watchTimer: number | null = null;
3124
private isWatching: boolean = false;
32-
private visibilityHandler: (() => void) | null = null;
33-
3425
private countedVideos = new Set<string>();
3526

27+
private enforcer = new YouTubeDomEnforcer();
28+
3629
constructor(
37-
private storage: StorageFacade,
3830
currentUrl: string,
3931
private onModeChange: () => void,
4032
) {
41-
const isShorts = currentUrl.includes('/shorts/');
42-
this.id = isShorts ? 'youtube_shorts' : 'youtube_watch';
43-
this.name = isShorts ? 'YouTube Shorts' : 'YouTube Watch';
33+
this.id = getPlatformType(currentUrl);
34+
this.name = this.id === 'youtube_shorts' ? 'YouTube Shorts' : 'YouTube Watch';
4435
}
4536

4637
private startOrResumeWatchTimer(id: string) {
@@ -49,15 +40,13 @@ export class YouTubeAdapter implements PlatformAdapter {
4940

5041
this.isWatching = true;
5142
this.watchTimer = window.setTimeout(() => {
52-
const currentId = this.getVideoId(window.location.href);
53-
43+
const currentId = getVideoId(window.location.href);
5444
if (!document.hidden && currentId === id) {
5545
if (!this.countedVideos.has(id)) {
5646
this.countedVideos.add(id);
5747
if (this.callback) this.callback(id);
5848
}
5949
}
60-
6150
this.isWatching = false;
6251
this.watchTimer = null;
6352
}, this.WATCH_THRESHOLD);
@@ -74,14 +63,13 @@ export class YouTubeAdapter implements PlatformAdapter {
7463
private handleVideoChange(id: string) {
7564
this.pendingVideoId = id;
7665
this.pauseWatchTimer();
77-
7866
if (!document.hidden) {
7967
this.startOrResumeWatchTimer(id);
8068
}
8169
}
8270

8371
public isCurrentPlatform(url: string): boolean {
84-
return url.includes('youtube.com');
72+
return isYouTubeUrl(url);
8573
}
8674

8775
public observe(onItemChange: ItemChangeCallback): void {
@@ -92,26 +80,13 @@ export class YouTubeAdapter implements PlatformAdapter {
9280
if (document.hidden) {
9381
this.pauseWatchTimer();
9482
} else {
95-
if (this.pendingVideoId && this.pendingVideoId === this.getVideoId(window.location.href)) {
83+
if (this.pendingVideoId && this.pendingVideoId === getVideoId(window.location.href)) {
9684
this.startOrResumeWatchTimer(this.pendingVideoId);
9785
}
9886
}
9987
};
10088
document.addEventListener('visibilitychange', this.visibilityHandler);
10189

102-
this.storageListener = (
103-
changes: Record<string, import('../../core/storage/driver').StorageChange>,
104-
) => {
105-
if (changes[`limitra_${this.id}_count`] || changes[`limitra_${this.id}_time_spent`]) {
106-
void this.storage.isCurrentlyBlocked(this.id).then((isBlocked: boolean) => {
107-
if (isBlocked) {
108-
this.executePunishment();
109-
}
110-
});
111-
}
112-
};
113-
this.storage.onChange(this.storageListener);
114-
11590
this.observeUrlChanges();
11691
}
11792

@@ -123,117 +98,40 @@ export class YouTubeAdapter implements PlatformAdapter {
12398
window.clearInterval(this.urlCheckInterval);
12499
this.urlCheckInterval = null;
125100
}
126-
127101
if (this.ytNavigationHandler) {
128102
window.removeEventListener('yt-navigate-finish', this.ytNavigationHandler);
129103
this.ytNavigationHandler = null;
130104
}
131-
132105
if (this.popstateHandler) {
133106
window.removeEventListener('popstate', this.popstateHandler);
134107
this.popstateHandler = null;
135108
}
136-
137109
if (this.visibilityHandler) {
138110
document.removeEventListener('visibilitychange', this.visibilityHandler);
139111
this.visibilityHandler = null;
140112
}
141113

142-
if (this.storageListener) {
143-
this.storage.removeListener(this.storageListener);
144-
this.storageListener = null;
145-
}
146-
147-
if (this.punishmentObserver) {
148-
this.punishmentObserver.disconnect();
149-
this.punishmentObserver = null;
150-
}
151-
152-
if (this.keydownHandler) {
153-
window.removeEventListener('keydown', this.keydownHandler, true);
154-
this.keydownHandler = null;
155-
}
114+
this.enforcer.stop();
156115

157-
document.body.classList.remove('limitra-global-punishment');
158116
this.callback = null;
159117
this.pendingVideoId = null;
160118
this.lastVideoId = null;
161119
}
162120

163121
public executePunishment(): void {
164-
if (this.punishmentObserver) return;
165-
166-
document.body.classList.add('limitra-global-punishment');
167-
168-
const silenceVideos = () => {
169-
const videos = document.querySelectorAll<HTMLVideoElement>('video');
170-
videos.forEach((video) => {
171-
if (!video.paused || !video.muted) {
172-
video.muted = true;
173-
video.pause();
174-
}
175-
});
176-
};
177-
178-
silenceVideos();
179-
180-
let scheduled = false;
181-
182-
this.punishmentObserver = new MutationObserver(() => {
183-
if (scheduled) return;
184-
scheduled = true;
185-
186-
requestAnimationFrame(() => {
187-
silenceVideos();
188-
scheduled = false;
189-
});
190-
});
191-
192-
const appContainer = document.querySelector('ytd-app') || document.body;
193-
this.punishmentObserver.observe(appContainer, {
194-
childList: true,
195-
subtree: true,
196-
});
197-
198-
this.keydownHandler = (e: KeyboardEvent) => {
199-
if (this.BLOCKED_KEYS.includes(e.key)) {
200-
e.preventDefault();
201-
e.stopPropagation();
202-
}
203-
};
204-
205-
window.addEventListener('keydown', this.keydownHandler, true);
206-
}
207-
208-
// Unified ID extraction supporting both Shorts and Watch URLs
209-
private getVideoId(url: string): string | null {
210-
if (url.includes('/shorts/')) {
211-
const match = url.match(/shorts\/([^?]+)/);
212-
return match ? match[1] : null;
213-
} else if (url.includes('/watch')) {
214-
try {
215-
const urlParams = new URLSearchParams(new URL(url).search);
216-
return urlParams.get('v');
217-
} catch {
218-
return null;
219-
}
220-
}
221-
return null;
122+
this.enforcer.enforce();
222123
}
223124

224125
private checkUrl() {
225126
const currentUrl = window.location.href;
226-
const isShorts = currentUrl.includes('/shorts/');
227-
const evaluatedMode: PlatformId = isShorts ? 'youtube_shorts' : 'youtube_watch';
127+
const evaluatedMode = getPlatformType(currentUrl);
228128

229-
// TRIGGER HOT-SWAP: If the user navigated across platform boundaries
230129
if (evaluatedMode !== this.id) {
231130
this.onModeChange();
232131
return;
233132
}
234133

235-
const currentId = this.getVideoId(currentUrl);
236-
134+
const currentId = getVideoId(currentUrl);
237135
if (currentId !== this.lastVideoId) {
238136
this.lastVideoId = currentId;
239137
if (currentId) {
@@ -254,9 +152,8 @@ export class YouTubeAdapter implements PlatformAdapter {
254152

255153
this.urlCheckInterval = window.setInterval(() => {
256154
this.checkUrl();
257-
258155
if (!document.hidden && !this.isWatching && this.pendingVideoId) {
259-
if (this.getVideoId(window.location.href) === this.pendingVideoId) {
156+
if (getVideoId(window.location.href) === this.pendingVideoId) {
260157
this.startOrResumeWatchTimer(this.pendingVideoId);
261158
}
262159
}

src/platforms/youtube/enforcer.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export class YouTubeDomEnforcer {
2+
private punishmentObserver: MutationObserver | null = null;
3+
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
4+
private readonly BLOCKED_KEYS = [' ', 'k', 'ArrowUp', 'ArrowDown'];
5+
6+
public enforce(): void {
7+
if (this.punishmentObserver) return;
8+
9+
document.body.classList.add('limitra-global-punishment');
10+
11+
const silenceVideos = () => {
12+
const videos = document.querySelectorAll<HTMLVideoElement>('video');
13+
videos.forEach((video) => {
14+
if (!video.paused || !video.muted) {
15+
video.muted = true;
16+
video.pause();
17+
}
18+
});
19+
};
20+
21+
silenceVideos();
22+
23+
let scheduled = false;
24+
this.punishmentObserver = new MutationObserver(() => {
25+
if (scheduled) return;
26+
scheduled = true;
27+
requestAnimationFrame(() => {
28+
silenceVideos();
29+
scheduled = false;
30+
});
31+
});
32+
33+
const appContainer = document.querySelector('ytd-app') || document.body;
34+
this.punishmentObserver.observe(appContainer, {
35+
childList: true,
36+
subtree: true,
37+
});
38+
39+
this.keydownHandler = (e: KeyboardEvent) => {
40+
if (this.BLOCKED_KEYS.includes(e.key)) {
41+
e.preventDefault();
42+
e.stopPropagation();
43+
}
44+
};
45+
window.addEventListener('keydown', this.keydownHandler, true);
46+
}
47+
48+
public stop(): void {
49+
if (this.punishmentObserver) {
50+
this.punishmentObserver.disconnect();
51+
this.punishmentObserver = null;
52+
}
53+
if (this.keydownHandler) {
54+
window.removeEventListener('keydown', this.keydownHandler, true);
55+
this.keydownHandler = null;
56+
}
57+
document.body.classList.remove('limitra-global-punishment');
58+
}
59+
}

src/platforms/youtube/parser.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { PlatformId } from '../../types';
2+
3+
/**
4+
* Determines whether a given URL belongs to YouTube.
5+
* This acts as a guard to prevent applying platform-specific logic on invalid domains.
6+
*/
7+
export function isYouTubeUrl(url: string): boolean {
8+
try {
9+
const parsedUrl = new URL(url);
10+
return parsedUrl.hostname.includes('youtube.com');
11+
} catch {
12+
return false;
13+
}
14+
}
15+
16+
/**
17+
* Extracts the YouTube video ID from supported URL formats (Shorts or Watch).
18+
* This is required for tracking unique content and enforcing limits per video.
19+
*/
20+
export function getVideoId(url: string): string | null {
21+
try {
22+
const parsedUrl = new URL(url);
23+
24+
if (parsedUrl.pathname.startsWith('/shorts/')) {
25+
const parts = parsedUrl.pathname.split('/');
26+
return parts[2] || null;
27+
} else if (parsedUrl.pathname.startsWith('/watch')) {
28+
return parsedUrl.searchParams.get('v');
29+
}
30+
31+
return null;
32+
} catch {
33+
return null;
34+
}
35+
}
36+
37+
/**
38+
* Resolves the YouTube platform type based on URL structure.
39+
* This ensures correct routing between Shorts and Watch logic in the system.
40+
*/
41+
export function getPlatformType(url: string): PlatformId {
42+
try {
43+
const parsedUrl = new URL(url);
44+
if (parsedUrl.pathname.startsWith('/shorts/')) {
45+
return 'youtube_shorts';
46+
}
47+
return 'youtube_watch';
48+
} catch {
49+
// Fallback to watch to avoid misclassification on malformed URLs
50+
return 'youtube_watch';
51+
}
52+
}

0 commit comments

Comments
 (0)