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(); });