diff --git a/package.json b/package.json
index 82b5153..d659f6b 100644
--- a/package.json
+++ b/package.json
@@ -22,9 +22,9 @@
"test:all": "npm run test:consumer && npm run test:tv-smoke",
"test:desktop-smoke": "node tests/run-desktop-smoke.cjs",
"test:packaged-smoke": "node tests/run-packaged-smoke.cjs",
- "test:tv-smoke": "node tests/run-tv-smoke.cjs && node tests/tv-audio-controls.cjs",
+ "test:tv-smoke": "node tests/run-tv-smoke.cjs && node tests/tv-audio-controls.cjs && node tests/tv-playback-evidence-smoke.cjs",
"qa:serve": "node tests/serve-static.cjs",
- "test:static": "node --check shared/series-metadata.js && node --check shared/search-ranking.js && node --check shared/live-tv.js && node --check shared/watch-browse.js && node --check shared/playback-providers.js && node --check shared/user-state.js && node --check shared/artwork-cache.js && node --check shared/release-channel.js && node --check shared/card-artwork.js && node --check app/catalog-data.js && node --check app/site-artwork.js && node --check app/config.js && node --check app/renderer.js && node --check electron/main.cjs && node --check electron/preload.cjs && node --check tv/config.js && node --check tv/tv.js && node --check scripts/configure-build.cjs && node --check scripts/render-icon.cjs && node --check scripts/render-tv-icon.cjs && node --check scripts/stage-tv.cjs && node --check scripts/package-current-release.cjs && node --check tests/run-desktop-smoke.cjs && node --check tests/run-packaged-smoke.cjs && node --check tests/run-tv-smoke.cjs && node --check tests/capture-tv.cjs && node --check tests/tv-harness.cjs && node --check tests/search-smoke.cjs && node --check tests/watch-filters-smoke.cjs && node --check tests/smoke-library.cjs && node --check tests/card-artwork-smoke.cjs && node tests/catalog-data.test.cjs && node tests/series-metadata.test.cjs && node tests/search-ranking.test.cjs && node tests/live-tv.test.cjs && node tests/watch-browse.test.cjs && node tests/playback-providers.test.cjs && node tests/user-state.test.cjs && node tests/artwork-cache.test.cjs && node tests/release-channel.test.cjs && node tests/static-audit.cjs && node tests/release-config.test.cjs",
+ "test:static": "node --check shared/series-metadata.js && node --check shared/search-ranking.js && node --check shared/live-tv.js && node --check shared/watch-browse.js && node --check shared/playback-providers.js && node --check shared/user-state.js && node --check shared/artwork-cache.js && node --check shared/release-channel.js && node --check shared/card-artwork.js && node --check app/catalog-data.js && node --check app/site-artwork.js && node --check app/config.js && node --check app/renderer.js && node --check electron/main.cjs && node --check electron/preload.cjs && node --check tv/config.js && node --check tv/tv.js && node --check scripts/configure-build.cjs && node --check scripts/render-icon.cjs && node --check scripts/render-tv-icon.cjs && node --check scripts/stage-tv.cjs && node --check scripts/package-current-release.cjs && node --check tests/run-desktop-smoke.cjs && node --check tests/run-packaged-smoke.cjs && node --check tests/run-tv-smoke.cjs && node --check tests/capture-tv.cjs && node --check tests/tv-harness.cjs && node --check tests/search-smoke.cjs && node --check tests/watch-filters-smoke.cjs && node --check tests/smoke-library.cjs && node --check tests/card-artwork-smoke.cjs && node tests/catalog-data.test.cjs && node tests/series-metadata.test.cjs && node tests/search-ranking.test.cjs && node tests/live-tv.test.cjs && node tests/watch-browse.test.cjs && node tests/playback-providers.test.cjs && node tests/tv-playback-evidence.test.cjs && node tests/user-state.test.cjs && node tests/artwork-cache.test.cjs && node tests/release-channel.test.cjs && node tests/static-audit.cjs && node tests/release-config.test.cjs",
"configure:build": "node scripts/configure-build.cjs --desktop",
"tv:stage": "node scripts/stage-tv.cjs",
"tv:lg": "npm run tv:stage && npx --yes --package @webos-tools/cli@3.2.5 ares-package tv/build/lg -o tv/release",
diff --git a/scripts/stage-tv.cjs b/scripts/stage-tv.cjs
index 815bf24..41ec5fd 100644
--- a/scripts/stage-tv.cjs
+++ b/scripts/stage-tv.cjs
@@ -72,7 +72,7 @@ const run = async () => {
.replace('../node_modules/hls.js/dist/hls.min.js', 'shared/hls.min.js');
fs.writeFileSync(path.join(androidAssets, 'index.html'), bundledTvHtml);
for (const file of ['config.js', 'tv.css', 'tv.js']) fs.copyFileSync(path.join(tvRoot, file), path.join(androidAssets, file));
- for (const file of ['series-metadata.js', 'live-tv.js', 'watch-browse.js']) fs.copyFileSync(path.join(root, 'shared', file), path.join(androidAssets, 'shared', file));
+ for (const file of ['series-metadata.js', 'live-tv.js', 'watch-browse.js', 'playback-providers.js']) fs.copyFileSync(path.join(root, 'shared', file), path.join(androidAssets, 'shared', file));
fs.copyFileSync(path.join(root, 'node_modules', 'hls.js', 'dist', 'hls.min.js'), path.join(androidAssets, 'shared', 'hls.min.js'));
fs.copyFileSync(path.join(root, 'assets', 'harbor-mark.svg'), path.join(androidAssets, 'assets', 'harbor-mark.svg'));
fs.copyFileSync(icon, path.join(androidAssets, 'assets', 'icon.png'));
@@ -84,6 +84,7 @@ const run = async () => {
const seriesMetadataSource = fs.readFileSync(path.join(root, 'shared', 'series-metadata.js'), 'utf8');
const liveTvSource = fs.readFileSync(path.join(root, 'shared', 'live-tv.js'), 'utf8');
const watchBrowseSource = fs.readFileSync(path.join(root, 'shared', 'watch-browse.js'), 'utf8');
+ const playbackProvidersSource = fs.readFileSync(path.join(root, 'shared', 'playback-providers.js'), 'utf8');
const hlsSource = fs.readFileSync(path.join(root, 'node_modules', 'hls.js', 'dist', 'hls.min.js'), 'utf8');
const tvConfigSource = stagedApiKey
? `window.HARBOR_CONFIG = Object.freeze({\n tmdbApiKey: ${JSON.stringify(stagedApiKey)}\n});\n`
@@ -98,6 +99,7 @@ const run = async () => {
.replace('', ``)
.replace('', ``)
.replace('', ``)
+ .replace('', () => ``)
.replace('', ``)
.replace('', ``)
.replace('', ``);
diff --git a/shared/playback-providers.js b/shared/playback-providers.js
index 4426c4c..656de26 100644
--- a/shared/playback-providers.js
+++ b/shared/playback-providers.js
@@ -96,6 +96,30 @@
return [provider.id, provider];
}));
+ // VidLink's official event schema spells its TMDB field "mtmdbId".
+ // https://vidlink.pro/ (Player Events). Only a title-bound positive playback
+ // event is evidence; page loads, metadata and failed attempts remain unknown.
+ function vidLinkPlaybackEvidence(payload, target, season, episode) {
+ function integer(value, allowZero) {
+ return (typeof value === 'string' || typeof value === 'number')
+ && (allowZero ? /^(0|[1-9]\d*)$/ : /^[1-9]\d*$/).test(String(value))
+ && Number.isSafeInteger(Number(value));
+ }
+ if (!target || !integer(target.tmdbId, false) || !payload || payload.type !== 'PLAYER_EVENT') return false;
+ var data = payload.data;
+ var mediaType = target.type === 'movie' ? 'movie' : (target.type === 'tv' || target.type === 'anime') ? 'tv' : '';
+ if (!data || !mediaType || data.mediaType !== mediaType || !integer(data.mtmdbId, false)
+ || String(data.mtmdbId) !== String(target.tmdbId)) return false;
+ if (mediaType === 'tv' && (!integer(season, true) || !integer(episode, false)
+ || !integer(data.season, true) || !integer(data.episode, false)
+ || String(data.season) !== String(season) || String(data.episode) !== String(episode))) return false;
+ return (data.event === 'play' || data.event === 'timeupdate')
+ && typeof data.currentTime === 'number' && Number.isFinite(data.currentTime)
+ && typeof data.duration === 'number' && Number.isFinite(data.duration)
+ && data.duration > 0 && data.currentTime >= 0 && data.currentTime <= data.duration
+ && (data.event !== 'timeupdate' || data.currentTime > 0);
+ }
+
function resolve(providerId, tmdbId, isSeries, season, episode) {
var provider = providerById[providerId] || providerById.vidlink;
var template = isSeries ? provider.seriesTemplate : provider.movieTemplate;
@@ -110,6 +134,7 @@
allowedHosts: Object.freeze([...new Set(providers.flatMap(function (provider) {
return [provider.host, ...(provider.navigationHosts || [])];
}))]),
+ vidLinkPlaybackEvidence: vidLinkPlaybackEvidence,
resolve: resolve
});
});
diff --git a/tests/static-audit.cjs b/tests/static-audit.cjs
index eb4290c..d3b3bd8 100644
--- a/tests/static-audit.cjs
+++ b/tests/static-audit.cjs
@@ -163,7 +163,7 @@ assert(!tvHtml.includes('allow-popups'), 'TV player grants popup permission.');
assert(tvHtml.includes('id="tv-frame" class="player-frame focusable"') && tvHtml.includes('tabindex="0"'), 'TV player is not reachable with a 5-way remote.');
assert(tvScript.includes('function focusPlayerFrame()') && tvScript.includes('tvFrame.contentWindow.focus()'), 'TV player does not hand remote focus to the embedded controls.');
assert(tvScript.includes('function revealPlayerControls(route)') && tvScript.includes('if (route) revealPlayerControls(route);'), 'TV player controls stay covered while waiting for autoplay.');
-assert(tvScript.includes("playerEvent === 'play' || playerEvent === 'timeupdate'"), 'TV player hides its loading screen before playback is proven ready.');
+assert(tvScript.includes('playbackProvidersApi.vidLinkPlaybackEvidence(event.data, state.active, state.season, state.episode)'), 'TV player does not validate title-bound playback evidence.');
assert(!tvScript.includes("event.data.type === 'MEDIA_DATA' ||"), 'TV player still treats catalog metadata as playback readiness.');
assert(tvScript.includes('autoplay=true'), 'TV playback does not start immediately after an episode is selected.');
assert(tvScript.includes('https://vidsrc.to/embed/'), 'TV playback has no secure fallback provider.');
diff --git a/tests/tv-playback-evidence-smoke.cjs b/tests/tv-playback-evidence-smoke.cjs
new file mode 100644
index 0000000..63ad81c
--- /dev/null
+++ b/tests/tv-playback-evidence-smoke.cjs
@@ -0,0 +1,71 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+const net = require('node:net');
+const { spawn } = require('node:child_process');
+const root = path.resolve(__dirname, '..');
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+const reservePort = () => new Promise(resolve => { const server = net.createServer(); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); });
+
+(async () => {
+ const port = await reservePort();
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-audio-controls-'));
+ const child = spawn(require('electron'), [path.join(root, 'tests/tv-harness.cjs'), String(port), '--in-process-gpu', '--disable-gpu-sandbox'], { cwd: root, env: { ...process.env, HARBOR_QA_PROFILE: profile }, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
+ child.stderr.on('data', data => process.stderr.write(data));
+ let socket;
+ try {
+ let target;
+ for (let i = 0; i < 120; i++) { try { target = (await fetch(`http://127.0.0.1:${port}/json`).then(r => r.json())).find(t => /\/tv\/index.html/.test(t.url)); } catch {} if (target) break; await delay(100); }
+ assert.ok(target, 'TV renderer loaded');
+ socket = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise(resolve => socket.addEventListener('open', resolve, { once: true }));
+ let id = 0;
+ const pending = new Map();
+ socket.addEventListener('message', event => { const message = JSON.parse(event.data); if (pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id); } });
+ const command = async (method, params = {}) => { const key = ++id; const result = new Promise(resolve => pending.set(key, resolve)); socket.send(JSON.stringify({ id: key, method, params })); const message = await result; if (message.error) throw Error(JSON.stringify(message.error)); return message.result; };
+ const evaluate = async expression => { const result = await command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true }); if (result.exceptionDetails) throw Error(JSON.stringify(result.exceptionDetails)); return result.result.value; };
+ await command('Page.enable');
+ await command('Network.enable');
+ await command('Network.setBlockedURLs', { urls:['https://*','http://*'] });
+ await command('Page.addScriptToEvaluateOnNewDocument', { source: `
+ window.fetch = () => Promise.reject(Error('Offline evidence fixture'));
+ window.evidenceTimers=[];
+ const nativeTimeout=window.setTimeout;
+ window.setTimeout=function(fn,ms,...args) { window.evidenceTimers.push(ms); return nativeTimeout(fn,ms,...args); };
+ const frameSrc=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'src');
+ Object.defineProperty(HTMLIFrameElement.prototype,'src',{...frameSrc,
+ get() { return this.__fixtureSrc || frameSrc.get.call(this); },
+ set(url) { this.__fixtureSrc=url; frameSrc.set.call(this,'about:blank'); }
+ });
+ ` });
+ if (process.argv[2]) await command('Page.navigate',{url:pathToFileURL(path.resolve(process.argv[2])).href});
+ else await command('Page.reload');
+ await evaluate(`(async()=>{ for(let n=0;n<160;n++) { if(window.HarborPlaybackProviders && document.querySelector('.media-card')) return; await new Promise(r=>setTimeout(r,50)); } throw Error('Evidence UI did not load'); })()`);
+ await evaluate(`document.querySelector('.media-card').click();document.querySelector('#detail-play').click()`);
+ await delay(1200);
+ assert.equal(await evaluate(`document.querySelector('#player-status').hidden`),true,'Loaded provider reveals usable controls');
+ const results=await evaluate(`(()=>{
+ const frame=document.querySelector('#tv-frame');
+ const id=Number(new URL(frame.src).pathname.split('/')[2]);
+ const payload={type:'PLAYER_EVENT',data:{event:'play',mtmdbId:id,mediaType:'movie',currentTime:0,duration:120}};
+ const send=(data,extra={})=>{ evidenceTimers=[]; window.dispatchEvent(new MessageEvent('message',{origin:'https://vidlink.pro',source:frame.contentWindow,data,...extra})); return evidenceTimers.filter(n=>n===900).length; };
+ const result={ wrongTitle:send({...payload,data:{...payload.data,mtmdbId:id+1}}),
+ missingTitle:send({...payload,data:{...payload.data,mtmdbId:null}}),
+ wrongOrigin:send(payload,{origin:'https://vidlink.pro.evil.test'}),
+ wrongSource:send(payload,{source:window}),
+ paused:send({...payload,data:{...payload.data,event:'pause'}}),
+ metadata:send({type:'MEDIA_DATA',data:{[id]:{progress:{watched:10,duration:120}}}}),
+ valid:send(payload) };
+ return result;
+ })()`);
+ assert.deepEqual(results,{wrongTitle:0,missingTitle:0,wrongOrigin:0,wrongSource:0,paused:0,metadata:0,valid:1});
+ await delay(1000);
+ assert.equal(await evaluate(`document.activeElement.id`),'tv-frame','Confirmed playback focuses usable player');
+ await evaluate(`document.querySelector('#player-back').click()`);
+ assert.equal(await evaluate(`document.querySelector('#player-panel').hidden`),true);
+ assert.equal(await evaluate(`(()=>{evidenceTimers=[];window.dispatchEvent(new MessageEvent('message',{origin:'https://vidlink.pro',source:document.querySelector('#tv-frame').contentWindow,data:{type:'PLAYER_EVENT',data:{event:'play',mtmdbId:1,mediaType:'movie',currentTime:0,duration:120}}}));return evidenceTimers.filter(n=>n===900).length;})()`),0,'Closed player ignores late messages');
+ console.log('Offline real TV DOM: frame reveal, forged/mismatched/metadata/paused rejection, positive title-bound readiness, focus and Back cleanup passed.');
+ } finally { socket?.close(); child.kill(); await Promise.race([new Promise(resolve => child.once('exit', resolve)), delay(3000)]); fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/tv-playback-evidence.test.cjs b/tests/tv-playback-evidence.test.cjs
new file mode 100644
index 0000000..60229ba
--- /dev/null
+++ b/tests/tv-playback-evidence.test.cjs
@@ -0,0 +1,74 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const path = require('node:path');
+const providers = require('../shared/playback-providers.js');
+const root = process.env.HARBOR_EVIDENCE_ROOT || path.resolve(__dirname, '..');
+const source = fs.readFileSync(path.join(root, 'tv/tv.js'), 'utf8');
+const handlers = {};
+const timers = new Map();
+let serial = 0, ready = 0, focused = 0;
+const route = {provider:'vidlink',url:'https://vidlink.pro/tv/42/1/2'};
+const frameWindow = {};
+const context = vm.createContext({
+ window:{addEventListener:(name,fn)=>handlers[name]=fn},
+ playbackProvidersApi:providers,
+ state:{active:{type:'anime',tmdbId:'42'},season:1,episode:2,playerMode:'embed',playerRoutes:[route],playerRouteIndex:0},
+ tvFrame:{contentWindow:frameWindow,src:route.url,hidden:false},playerPanel:{hidden:false},
+ playerStatus:{hidden:false},playerReady:false,playerRouteTimer:101,playerReadyTimer:102,playerControlTimer:103,
+ clearTimeout:id=>timers.delete(id),setTimeout:fn=>{timers.set(++serial,fn);return serial;},
+ focusPlayerFrame:()=>focused++,markPlayerReady:()=>ready++,
+});
+const listenerStart = source.indexOf(" window.addEventListener('message',");
+vm.runInContext(source.slice(listenerStart,source.indexOf(" tvVideo.addEventListener('canplay'",listenerStart)),context);
+const payload = {type:'PLAYER_EVENT',data:{event:'play',mtmdbId:42,mediaType:'tv',season:1,episode:2,currentTime:0,duration:120}};
+const send = (data=payload, extra={})=>handlers.message({origin:'https://vidlink.pro',source:frameWindow,data,...extra});
+// This fails against PR18: the old handler accepts a different episode.
+send({...payload,data:{...payload.data,episode:3}});
+assert.equal(ready,0,'Another episode must not confirm the active player');
+for (const data of [null,{}, {type:'PLAYER_EVENT'}, {...payload,data:{...payload.data,mtmdbId:43}},
+ {...payload,data:{...payload.data,mtmdbId:null}}, {...payload,data:{...payload.data,mediaType:'movie'}},
+ {...payload,data:{...payload.data,season:2}}, {...payload,data:{...payload.data,event:'pause'}},
+ {...payload,data:{...payload.data,duration:NaN}}, {...payload,data:{...payload.data,currentTime:-1}},
+ {...payload,data:{...payload.data,mtmdbId:undefined,tmdbId:42}}]) send(data);
+send(payload,{origin:'https://vidlink.pro.evil.test'}); send(payload,{source:{}});
+assert.equal(ready,0,'Malformed, mismatched, paused and forged events stay unknown');
+context.playerPanel.hidden=true;send(); context.playerPanel.hidden=false;
+context.tvFrame.hidden=true;send();context.tvFrame.hidden=false;
+context.state.playerMode='audio';send();context.state.playerMode='embed';
+context.state.playerRouteIndex=1;send();context.state.playerRouteIndex=0;
+context.tvFrame.src='https://vidsrc.to/embed/tv/42/1/2';send();context.tvFrame.src=route.url;
+route.provider='vidsrc';send();route.provider='vidlink';
+context.state.active.tmdbId='43';send({...payload,data:{...payload.data,mtmdbId:43}});context.state.active.tmdbId='42';
+assert.equal(ready,0,'Inactive, superseded, hidden and other-provider frames stay unknown');
+send(); assert.equal(ready,1,'Exact documented movie/episode identity confirms playback');
+send({...payload,data:{...payload.data,event:'timeupdate',currentTime:4}});assert.equal(ready,2);
+// Load must reveal controls without claiming playback or interrupting a Play click.
+vm.runInContext(source.slice(source.indexOf(' function revealPlayerControls('),source.indexOf(' function activeAudio(')),context);
+vm.runInContext('revealPlayerControls(state.playerRoutes[0])',context);
+assert.equal(context.playerRouteTimer,null,'Loaded provider must not time out while the user clicks Play');
+const reveal = timers.get(context.playerControlTimer);reveal();
+assert.equal(context.playerStatus.hidden,true);assert.equal(focused,1);assert.equal(ready,2);
+context.playerStatus.hidden=false;
+vm.runInContext('revealPlayerControls(state.playerRoutes[0])',context);
+context.state.playerRoutes[0]={provider:'vidsrc',url:'https://vidsrc.to/embed/tv/42/1/2'};
+timers.get(context.playerControlTimer)();assert.equal(context.playerStatus.hidden,false,'Superseded load callback cannot hide new status');
+// Execute the actual frame-load callback and drain its timers: a VidSrc page
+// stays interactive while playback evidence remains unknown.
+context.tvFrame.addEventListener=(name,fn)=>handlers['frame-'+name]=fn;
+vm.runInContext(source.slice(source.indexOf(" tvFrame.addEventListener('load'"),source.indexOf(" tvFrame.addEventListener('focus'")),context);
+context.tvFrame.src=context.state.playerRoutes[0].url;timers.clear();
+handlers['frame-load']();for (const fn of [...timers.values()]) fn();
+assert.equal(ready,2,'Iframe load must not confirm playback');
+assert.equal(context.playerStatus.hidden,true,'Loaded VidSrc controls remain usable');
+if (providers.vidLinkPlaybackEvidence) {
+ const target={tmdbId:'42',type:'movie'};
+ const movie={...payload,data:{...payload.data,mediaType:'movie'}};
+ assert(providers.vidLinkPlaybackEvidence(movie,target));
+ assert(!providers.vidLinkPlaybackEvidence(movie,{tmdbId:'43',type:'movie'}));
+ assert(!providers.vidLinkPlaybackEvidence(movie,{tmdbId:42,type:'music'}));
+ for (const patch of [{mtmdbId:true},{mtmdbId:'042'},{mtmdbId:Infinity},{duration:0},{currentTime:121},{event:'seeked'},{currentTime:'4'},{duration:Infinity}])
+ assert(!providers.vidLinkPlaybackEvidence({...movie,data:{...movie.data,...patch}},target));
+ assert(!providers.vidLinkPlaybackEvidence({...movie,data:{...movie.data,event:'timeupdate',currentTime:0}},target));
+}
+console.log('Title-bound VidLink evidence, source/origin/route guards, usable loaded controls and stale callback rejection passed.');
diff --git a/tv/index.html b/tv/index.html
index e366e8e..3ad44cb 100644
--- a/tv/index.html
+++ b/tv/index.html
@@ -29,6 +29,7 @@
+
diff --git a/tv/tv.js b/tv/tv.js
index f59e8ca..b0be3b7 100644
--- a/tv/tv.js
+++ b/tv/tv.js
@@ -54,6 +54,7 @@
var state = { section: 'Watch', subcategory: 'All', watchFilter: '', liveCountry: '', liveLanguage: '', liveWindow: 'now', liveFacets: { countries: [], languages: [], sports: [] }, liveTotal: 0, liveCached: false, page: 1, items: [], active: null, saved: [], history: [], query: '', canLoadMore: false, loading: false, seasons: [], season: 1, episode: 1, episodePage: 0, playerRoutes: [], playerRouteIndex: 0, playerMode: '' };
var seriesMetadataCache = {};
var itemLoadGeneration = 0;
+ var playbackProvidersApi = window.HarborPlaybackProviders;
var playerReady = false;
var EPISODE_PAGE_SIZE = 30;
var playerRouteTimer = null;
@@ -857,6 +858,10 @@
}
function revealPlayerControls(route) {
+ // A loaded frame can need a Play click. Keep it usable without treating a
+ // page load as playback or switching servers while the user interacts.
+ clearTimeout(playerRouteTimer);
+ playerRouteTimer = null;
clearTimeout(playerControlTimer);
playerControlTimer = setTimeout(function () {
if (playerPanel.hidden || tvFrame.hidden || state.playerRoutes[state.playerRouteIndex] !== route) return;
@@ -1238,12 +1243,7 @@
markPlayerReady();
return;
}
- if (state.playerMode === 'embed' && route && route.provider !== 'vidlink') {
- clearTimeout(playerReadyTimer);
- playerReadyTimer = setTimeout(function () {
- if (!playerPanel.hidden && !playerReady && state.playerRoutes[state.playerRouteIndex] === route) markPlayerReady();
- }, 1200);
- }
+
});
tvFrame.addEventListener('focus', function () {
try {
@@ -1251,9 +1251,13 @@
} catch (_) {}
});
window.addEventListener('message', function (event) {
- if (event.origin !== 'https://vidlink.pro' || event.source !== tvFrame.contentWindow || playerPanel.hidden || !event.data) return;
- var playerEvent = event.data.type === 'PLAYER_EVENT' && event.data.data && event.data.data.event;
- if (playerEvent === 'play' || playerEvent === 'timeupdate') markPlayerReady();
+ var route = state.playerRoutes[state.playerRouteIndex];
+ if (event.origin !== 'https://vidlink.pro' || event.source !== tvFrame.contentWindow
+ || playerPanel.hidden || tvFrame.hidden || state.playerMode !== 'embed'
+ || !route || route.provider !== 'vidlink' || tvFrame.src !== route.url || !state.active
+ || route.url.split('?')[0] !== playbackProvidersApi.resolve('vidlink', state.active.tmdbId,
+ state.active.type === 'tv' || state.active.type === 'anime', state.season, state.episode)) return;
+ if (playbackProvidersApi.vidLinkPlaybackEvidence(event.data, state.active, state.season, state.episode)) markPlayerReady();
});
tvVideo.addEventListener('canplay', function () { markPlayerReady(); });
tvAudio.addEventListener('canplay', function () { if (activeAudio()) markPlayerReady(); });