Skip to content

Commit 9fc0a04

Browse files
committed
feat(youtube): add YouTube Watch support with hot-swap and platform isolation
- Add youtube_watch as a second tracked platform with 10s watch threshold - Implement hot-swap in content.ts: destroys and re-bootstraps engine on platform boundary crossings (Shorts - Watch) - Add destroy() lifecycle methods to AppOrchestrator, SessionManager, and Tracker to prevent memory leaks during context switches - Filter BLOCK_NOW messages by PlatformId to prevent cross-platform message crosstalk - Migrate PlatformAdapter interface to src/core/interfaces/ - Update i18n and popup UI to expose YouTube Watch controls
1 parent bc741d3 commit 9fc0a04

16 files changed

Lines changed: 337 additions & 112 deletions

File tree

src/app/orchestrator.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { SessionManager } from '../core/session';
55
import { showOverlay, initOverlayListeners } from '../ui/overlay/controller';
66
import { ConnectionManager } from '../core/interfaces/connection-manager';
77
import { StorageFacade } from '../core/storage/index';
8-
import { PlatformAdapter } from '../types';
8+
import { PlatformAdapter } from '../core/interfaces/platform-adapter';
99
import { StorageChange } from '../core/storage/driver';
1010
import { MessageBus } from '../core/interfaces/message-bus';
1111

@@ -17,6 +17,10 @@ export class AppOrchestrator {
1717
private storage: StorageFacade;
1818

1919
private sessionManager!: SessionManager;
20+
private messenger!: Messenger;
21+
private tracker!: Tracker;
22+
23+
private storageListener: ((changes: Record<string, StorageChange>) => void) | null = null;
2024

2125
constructor(
2226
adapter: PlatformAdapter,
@@ -32,9 +36,13 @@ export class AppOrchestrator {
3236

3337
public async start() {
3438
initOverlayListeners();
39+
40+
const pId = this.activeAdapter.id;
41+
3542
const safeBlock = async (reason: string = 'time') => {
3643
if (!this.isBlocked) {
3744
this.isBlocked = true;
45+
console.warn(`[Limitra] Initiating block logic. Reason: ${reason}`);
3846
if (this.sessionManager) {
3947
await this.sessionManager.blockSession();
4048
}
@@ -43,21 +51,24 @@ export class AppOrchestrator {
4351
}
4452
};
4553

46-
const messenger = new Messenger(() => {
47-
void safeBlock('time');
48-
}, this.messageBus);
49-
messenger.init();
54+
this.messenger = new Messenger(
55+
() => {
56+
void safeBlock('time');
57+
},
58+
this.messageBus,
59+
pId,
60+
);
61+
62+
this.messenger.init();
5063

5164
this.sessionManager = new SessionManager(
52-
messenger,
65+
this.messenger,
5366
this.activeAdapter,
5467
this.connectionManager,
5568
this.storage,
5669
);
5770
await this.sessionManager.init();
5871

59-
const pId = this.activeAdapter.id;
60-
6172
let limit = await this.storage.getLimit(pId);
6273
const initialCount = await this.storage.getCount(pId);
6374
let isLimitEnabled = await this.storage.getEnableLimit(pId);
@@ -77,7 +88,7 @@ export class AppOrchestrator {
7788
);
7889
limiter.setInitialCount(initialCount);
7990

80-
this.storage.onChange((changes: Record<string, StorageChange>) => {
91+
this.storageListener = (changes: Record<string, StorageChange>) => {
8192
if (changes[`limitra_${pId}_limit`] || changes[`limitra_${pId}_enable_limit`]) {
8293
if (changes[`limitra_${pId}_limit`]) {
8394
limit = Number(changes[`limitra_${pId}_limit`].newValue) || 0;
@@ -114,7 +125,9 @@ export class AppOrchestrator {
114125
void safeBlock('time');
115126
}
116127
}
117-
});
128+
};
129+
130+
this.storage.onChange(this.storageListener);
118131

119132
const bypassed = await this.storage.detectBypass(pId);
120133
if (bypassed) {
@@ -125,7 +138,7 @@ export class AppOrchestrator {
125138
await safeBlock('time');
126139
}
127140

128-
const tracker = new Tracker(async () => {
141+
this.tracker = new Tracker(async () => {
129142
if (this.isBlocked) {
130143
const overlay = document.getElementById('limitra-overlay');
131144
const isTampered =
@@ -135,19 +148,43 @@ export class AppOrchestrator {
135148
window.getComputedStyle(overlay).opacity === '0';
136149
if (isTampered) {
137150
console.warn(
138-
'[Limitra] Tampering with the blocking interface via DevTools has been detected. Reapplying enforcement...',
151+
'[Limitra] Tampering or SPA page refresh detected. Reapplying enforcement...',
139152
);
140153
if (overlay) overlay.remove();
141154
this.activeAdapter.executePunishment();
142155
await showOverlay('bypass');
143156
}
144157
return;
145158
}
159+
146160
if (isLimitEnabled && limit > 0) {
147161
await this.storage.incrementCount(pId);
148162
}
149163
});
150-
tracker.setAdapter(this.activeAdapter);
151-
tracker.init();
164+
this.tracker.setAdapter(this.activeAdapter);
165+
this.tracker.init();
166+
}
167+
168+
public destroy() {
169+
if (this.storageListener) {
170+
this.storage.removeListener(this.storageListener);
171+
this.storageListener = null;
172+
}
173+
if (this.messenger) {
174+
this.messenger.destroy();
175+
}
176+
if (this.activeAdapter) {
177+
this.activeAdapter.disconnect();
178+
}
179+
if (this.sessionManager) {
180+
this.sessionManager.destroy();
181+
}
182+
if (this.tracker) {
183+
this.tracker.destroy();
184+
}
185+
186+
const overlay = document.getElementById('limitra-overlay');
187+
if (overlay) overlay.remove();
188+
document.body.classList.remove('limitra-global-punishment');
152189
}
153190
}

src/content.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,53 @@ if (!window.__LIMITRA_INJECTED__) {
1515
window.__LIMITRA_INJECTED__ = true;
1616

1717
void (async () => {
18-
try {
19-
const storageDriver = new ChromeStorageDriver();
20-
const storage = new StorageFacade(storageDriver);
21-
const messageBus = new ChromeMessageBus();
22-
const connectionManager = new ChromeConnectionManager();
23-
24-
const adapters = [new YouTubeAdapter(storage)];
25-
const currentUrl = location.href;
26-
const activeAdapter = adapters.find((adapter) => adapter.isCurrentPlatform(currentUrl));
27-
28-
if (!activeAdapter) {
29-
console.warn('[Limitra] Site not supported yet. Sleeping...');
30-
return;
18+
const storageDriver = new ChromeStorageDriver();
19+
const storage = new StorageFacade(storageDriver);
20+
const messageBus = new ChromeMessageBus();
21+
const connectionManager = new ChromeConnectionManager();
22+
23+
let currentApp: AppOrchestrator | null = null;
24+
25+
let isBootstrapping = false;
26+
27+
const bootstrap = async () => {
28+
if (isBootstrapping) return;
29+
isBootstrapping = true;
30+
31+
try {
32+
if (currentApp) {
33+
console.warn('[Limitra] Platform change detected. Destroying old instance...');
34+
currentApp.destroy();
35+
currentApp = null;
36+
}
37+
38+
const currentUrl = location.href;
39+
40+
const adapters = [
41+
new YouTubeAdapter(storage, currentUrl, () => {
42+
void bootstrap();
43+
}),
44+
];
45+
46+
const activeAdapter = adapters.find((adapter) => adapter.isCurrentPlatform(currentUrl));
47+
48+
if (!activeAdapter) {
49+
console.warn('[Limitra] Site not supported yet or context irrelevant. Sleeping...');
50+
return;
51+
}
52+
53+
currentApp = new AppOrchestrator(activeAdapter, messageBus, connectionManager, storage);
54+
await currentApp.start();
55+
56+
console.warn(`[Limitra] engine started for: ${activeAdapter.id}`);
57+
} catch (err) {
58+
console.error('[Limitra] Bootstrap failed:', err);
59+
window.__LIMITRA_INJECTED__ = false;
60+
} finally {
61+
isBootstrapping = false;
3162
}
63+
};
3264

33-
const app = new AppOrchestrator(activeAdapter, messageBus, connectionManager, storage);
34-
await app.start();
35-
} catch (err) {
36-
console.error('[Limitra] Bootstrap failed:', err);
37-
window.__LIMITRA_INJECTED__ = false;
38-
}
65+
await bootstrap();
3966
})();
4067
}

src/core/background-orchestrator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class BackgroundOrchestrator {
4040
const limitMs = timeLimitMins * 60 * 1000;
4141

4242
if (timeSpentMs >= limitMs) {
43-
const message: ExtensionMessage = { action: AppAction.BLOCK_NOW };
43+
const message: ExtensionMessage = { action: AppAction.BLOCK_NOW, platform };
4444
await this.tabManager.sendMessageToPattern(config.urlPatterns, message);
4545
} else {
4646
const timeRemainingMs = limitMs - timeSpentMs;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { PlatformId } from '../../types';
2+
3+
export type ItemChangeCallback = (itemId: string) => void;
4+
5+
/**
6+
* PlatformAdapter Port: Defines the contract for any supported platform (YouTube, IG, etc.)
7+
*/
8+
export interface PlatformAdapter {
9+
/**
10+
* Unique identifier for the platform (used for scoped storage and dynamic tracking)
11+
*/
12+
readonly id: PlatformId;
13+
14+
/**
15+
* Platform name used for UI and logging
16+
*/
17+
name: string;
18+
19+
/**
20+
* Checks whether the current URL belongs to this platform and its specific mode
21+
*/
22+
isCurrentPlatform(url: string): boolean;
23+
24+
/**
25+
* Starts observing platform-specific events (e.g., video changes, scrolling)
26+
*/
27+
observe(onItemChange: ItemChangeCallback): void;
28+
29+
/**
30+
* Cleans up all observers, intervals, and listeners
31+
*/
32+
disconnect(): void;
33+
34+
/**
35+
* Applies the enforcement/punishment logic (e.g., pause, mute, blur)
36+
*/
37+
executePunishment(): void;
38+
39+
/**
40+
* Checks if media is currently active (playing) for accurate time tracking
41+
*/
42+
isVideoPlaying(): boolean;
43+
}

src/core/messenger.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
import { AppAction, ExtensionMessage } from '../types';
2+
import { PlatformId } from '../types';
23
import { MessageBus } from './interfaces/message-bus';
34

45
export class Messenger {
56
private onBlockCallback: () => void;
67
private messageBus: MessageBus;
8+
private platformId: PlatformId;
79

810
private messageListener = (message: ExtensionMessage) => {
911
if (message && message.action === AppAction.BLOCK_NOW) {
10-
console.warn('[Messenger] Received BLOCK_NOW from background');
12+
if (message.platform && message.platform !== this.platformId) {
13+
return;
14+
}
15+
console.warn('[Messenger] Received BLOCK_NOW from background for:', this.platformId);
1116
this.onBlockCallback();
1217
}
1318
};
1419

15-
constructor(onBlock: () => void, messageBus: MessageBus) {
20+
constructor(onBlock: () => void, messageBus: MessageBus, platformId: PlatformId) {
1621
this.onBlockCallback = onBlock;
1722
this.messageBus = messageBus;
23+
this.platformId = platformId;
1824
}
1925

2026
public init() {
@@ -27,10 +33,10 @@ export class Messenger {
2733
}
2834

2935
public notifyActive() {
30-
void this.messageBus.sendMessage({ action: AppAction.TAB_ACTIVE });
36+
void this.messageBus.sendMessage({ action: AppAction.TAB_ACTIVE, platform: this.platformId });
3137
}
3238

3339
public notifyHidden() {
34-
void this.messageBus.sendMessage({ action: AppAction.TAB_HIDDEN });
40+
void this.messageBus.sendMessage({ action: AppAction.TAB_HIDDEN, platform: this.platformId });
3541
}
3642
}

src/core/session.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { StorageFacade } from './storage/index';
22
import { Messenger } from './messenger';
3-
import { PlatformAdapter } from '../types';
3+
import { PlatformAdapter } from '../core/interfaces/platform-adapter';
44
import { ConnectionManager } from './interfaces/connection-manager';
55

66
export class SessionManager {
@@ -14,6 +14,16 @@ export class SessionManager {
1414
private heartbeatId: ReturnType<typeof window.setInterval> | null = null;
1515
private connectionManager: ConnectionManager;
1616

17+
private activityHandler = () => {
18+
this.lastActivityTime = Date.now();
19+
};
20+
21+
private visibilityHandler = async () => {
22+
if (this.isBlocked) return;
23+
if (document.hidden) await this.stopTracking();
24+
else this.lastActivityTime = Date.now();
25+
};
26+
1727
constructor(
1828
messenger: Messenger,
1929
adapter: PlatformAdapter,
@@ -44,13 +54,7 @@ export class SessionManager {
4454

4555
private setupActivityListeners() {
4656
['mousemove', 'keydown', 'scroll', 'click'].forEach((event) => {
47-
window.addEventListener(
48-
event,
49-
() => {
50-
this.lastActivityTime = Date.now();
51-
},
52-
{ passive: true },
53-
);
57+
window.addEventListener(event, this.activityHandler, { passive: true });
5458
});
5559
}
5660

@@ -119,14 +123,25 @@ export class SessionManager {
119123
}
120124

121125
private observeVisibility() {
122-
document.addEventListener('visibilitychange', async () => {
123-
if (this.isBlocked) return;
124-
if (document.hidden) await this.stopTracking();
125-
else this.lastActivityTime = Date.now();
126-
});
126+
document.addEventListener('visibilitychange', this.visibilityHandler);
127127
}
128128

129129
private setupUnloadHandler() {
130130
this.connectionManager.connect('limitra-session');
131131
}
132+
133+
public destroy() {
134+
if (this.heartbeatId !== null) {
135+
window.clearInterval(this.heartbeatId);
136+
this.heartbeatId = null;
137+
}
138+
139+
['mousemove', 'keydown', 'scroll', 'click'].forEach((event) => {
140+
window.removeEventListener(event, this.activityHandler);
141+
});
142+
143+
document.removeEventListener('visibilitychange', this.visibilityHandler);
144+
145+
void this.stopTracking();
146+
}
132147
}

0 commit comments

Comments
 (0)