diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4dd0436..5953965 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,8 +21,8 @@ jobs: - name: Install FFmpeg run: sudo apt-get update && sudo apt-get install -y ffmpeg - run: npm ci - - name: Install browser for subtitle regression tests - run: npx playwright install --with-deps chromium + - name: Install Chromium and WebKit for browser regressions + run: npx playwright install --with-deps chromium webkit - run: npm test env: NODECAST_BROWSER_TESTS: '1' diff --git a/package-lock.json b/package-lock.json index d2839eb..b4d018f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodecast-tv", - "version": "2.2.6", + "version": "2.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodecast-tv", - "version": "2.2.6", + "version": "2.2.7", "license": "GPL-3.0-only", "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", diff --git a/package.json b/package.json index 8a0c462..16037d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodecast-tv", - "version": "2.2.6", + "version": "2.2.7", "description": "Modern IPTV Player", "license": "GPL-3.0-only", "main": "server/index.js", diff --git a/public/css/main.css b/public/css/main.css index dedba28..c09a38c 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -3363,8 +3363,8 @@ kbd { height: calc(100vh - var(--navbar-height)); /* fallback */ height: calc(100dvh - var(--navbar-height)); - /* dynamic viewport for modern browsers */ - min-height: 400px; + /* Keep controls inside short landscape viewports too. */ + min-height: 0; background: #000; } diff --git a/public/index.html b/public/index.html index f0063a5..9105e16 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ nodecast-tv | IPTV Player - + - + @@ -1119,7 +1119,7 @@ - + diff --git a/public/js/components/CaptionTracks.js b/public/js/components/CaptionTracks.js index dd13751..e93baf5 100644 --- a/public/js/components/CaptionTracks.js +++ b/public/js/components/CaptionTracks.js @@ -50,6 +50,7 @@ class CaptionTracks { this.generation++; this.hls = null; this.probe = null; + this.pendingRemoteSelection = null; this.timeOffset = 0; this.retireNativeTracks(); this.onChange(); @@ -124,9 +125,18 @@ class CaptionTracks { this.remoteTracks.push(element); this.video.appendChild(element); } + const selected = this.remoteTracks[this.pendingRemoteSelection]; + this.pendingRemoteSelection = null; + if (selected) selected.track.mode = 'showing'; + } + + captureRemoteSelection() { + this.pendingRemoteSelection = this.remoteTracks.findIndex(element => element.track.mode === 'showing'); } bindHls(hls, events) { + const selected = this.remoteTracks.findIndex(element => element.track.mode === 'showing'); + if (selected >= 0) this.pendingRemoteSelection = selected; this.generation++; this.hls = hls; this.manifestReady = false; diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index e7f2c71..7fa611d 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -117,15 +117,6 @@ class WatchPage { window.addEventListener('resize', updateIosUiBottom); } - // iOS: use custom --vh unit to avoid 100vh issues with dynamic toolbar - const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent); - const watchVideoSection = document.querySelector('.watch-video-section'); - if (isIOS && watchVideoSection) { - const vh = window.innerHeight * 0.01; - document.documentElement.style.setProperty('--vh', `${vh}px`); - watchVideoSection.style.height = 'calc(var(--vh) * 100)'; - } - // Apply safe area + iOS toolbar padding to overlay if (this.overlay) { this.overlay.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + var(--ios-ui-bottom, 0px) + 12px)'; @@ -481,17 +472,9 @@ class WatchPage { this.setVolumeFromStorage(); return; } else if (info.needsRemux) { - // Remux (container swap) currently doesn't use session logic, uses direct stream - // TODO: Move remux to session logic if seeking is needed for TS files - console.log('[WatchPage] Auto: Using remux (.ts container)'); + console.log('[WatchPage] Auto: Using seekable remux session'); this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); - this.getCaptionTracks().useNative(); - const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio], program); - this.video.src = finalUrl; - this.video.play().catch(e => { - if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); - }); - this.setVolumeFromStorage(); + await this.playRemuxSession(url, info, program, playbackGeneration); return; } // Compatible - fall through to normal playback @@ -563,14 +546,7 @@ class WatchPage { } } if (this.playbackGeneration !== playbackGeneration) return; - this.getCaptionTracks().setProbe(probeInfo, url); - this.getCaptionTracks().useNative(); - const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []), program); - this.video.src = finalUrl; - this.video.play().catch(e => { - if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e); - }); - this.setVolumeFromStorage(); + await this.playRemuxSession(url, probeInfo, program, playbackGeneration); return; } @@ -598,11 +574,22 @@ class WatchPage { this.setVolumeFromStorage(); } - /** - * Play HLS stream using Hls.js - */ + /** Start a seekable VOD session without re-encoding compatible video. */ + async playRemuxSession(url, info, program, generation) { + this.getCaptionTracks().setProbe(info, url); + const playlist = await this.startTranscodeSession(url, { + program, videoMode: 'copy', seekOffset: this.resumeTime, + videoCodec: info?.video, audioCodec: info?.audio, + audioChannels: info?.audioChannels, videoHeight: info?.height + }); + if (!playlist || this.playbackGeneration !== generation) return; + this.playHls(playlist); + this.setVolumeFromStorage(); + } + playHls(url) { if (this.hls) { + this.captionTracks?.captureRemoteSelection(); this.hls.destroy(); } @@ -631,6 +618,15 @@ class WatchPage { }); }); + this.hls.on(Hls.Events.LEVEL_LOADED, (event, data) => { + if (this.hls !== hls || this.playbackGeneration !== generation) return; + // A partial playlist is not the movie's total. Only learn an unknown duration at ENDLIST. + const duration = Number(data?.details?.totalduration); + if (this.currentSessionId && !this.sourceDuration && data?.details?.live === false && Number.isFinite(duration) && duration > 0) { + this.setSourceDuration((this.playbackTimeOffset || 0) + duration); + } + }); + this.hls.on(Hls.Events.ERROR, (event, data) => { if (this.hls !== hls || this.playbackGeneration !== generation) return; const mediaError = data.type === Hls.ErrorTypes.MEDIA_ERROR; @@ -737,6 +733,7 @@ class WatchPage { if (!this.currentSessionId) { this.video.currentTime = target; return; } const generation = this.playbackGeneration; this.pendingSeekProgress = target; + this.captionTracks?.captureRemoteSelection(); this.hls?.destroy(); this.hls = null; this.video.pause(); diff --git a/test/authentication.test.js b/test/authentication.test.js index fdadcf5..6daf984 100644 --- a/test/authentication.test.js +++ b/test/authentication.test.js @@ -217,3 +217,63 @@ test('legacy case-only accounts retain exact logins and reject ambiguous case va const resolved = await fixture.request('/api/auth/login', post({ username: 'TWIN', password: 'first-password' })); assert.equal(resolved.status, 200); assert.equal(resolved.body.user.username, 'Twin'); }); + +test('recorded TS movies and series use copy sessions, preserve totals and seek on a provider without ranges', + { skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 90000 }, async t => { + const { chromium }=require('playwright'); + const fixture=await serverFixture(t); + const binary=spawnSync('ffmpeg',['-version'],{stdio:'ignore'}).status===0?'ffmpeg':require('ffmpeg-static'); + const recorded=path.join(fixture.dir,'recorded.ts'); + const generated=spawnSync(binary,['-hide_banner','-loglevel','error','-y', + '-f','lavfi','-i','color=c=red:s=160x90:r=10:d=12','-f','lavfi','-i','color=c=blue:s=160x90:r=10:d=12', + '-f','lavfi','-i','sine=frequency=1000:sample_rate=48000:duration=24', + '-filter_complex','[0:v][1:v]concat=n=2:v=1:a=0[v]','-map','[v]','-map','2:a', + '-c:v','libx264','-threads','1','-preset','ultrafast','-g','10','-c:a','aac','-ac','2','-f','mpegts',recorded],{encoding:'utf8',timeout:15000}); + assert.equal(generated.status,0,generated.stderr); + const media=fs.readFileSync(recorded),provider=http.createServer((req,res)=>{ + // This origin always ignores Range and sends the full recording progressively. + res.writeHead(200,{'Content-Type':'video/mp2t'});res.write(media);res.end(); + }); + await new Promise(resolve=>provider.listen(0,'127.0.0.1',resolve)); + t.after(()=>new Promise(resolve=>{provider.close(resolve);provider.closeAllConnections();})); + const source=`http://127.0.0.1:${provider.address().port}/recorded.ts`; + const admin=await fixture.request('/api/auth/setup',post({username:'Owner',password:'owner-password'}));assert.equal(admin.status,201); + const cookie=admin.cookie.split(';')[0],headers={Cookie:cookie}; + const browser=await chromium.launch({headless:true,executablePath:process.env.NODECAST_BROWSER_EXECUTABLE||undefined});t.after(()=>browser.close()); + const context=await browser.newContext();const split=cookie.indexOf('='); + await context.addCookies([{name:cookie.slice(0,split),value:cookie.slice(split+1),url:fixture.base}]); + const page=await context.newPage(),requests=[],errors=[]; + page.on('pageerror',error=>errors.push(error.message)); + page.on('request',request=>{if(request.url().includes('/api/transcode/session'))requests.push(JSON.parse(request.postData()));}); + await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js',route=>route.fulfill({contentType:'application/javascript',body:fs.readFileSync(require.resolve('hls.js/dist/hls.min.js'))})); + await page.route('https://fonts.**',route=>route.abort()); + await page.route('**/api/probe?**',async route=>{const response=await route.fetch();const info=await response.json();await route.fulfill({response,json:{...info,duration:0}});}); + await page.goto(fixture.base);await page.waitForFunction(()=>window.app?.pages?.watch); + for(const [type,settings]of [['movie',{autoTranscode:true,forceRemux:false}],['series',{autoTranscode:false,forceRemux:true}]]){ + await fixture.request('/api/settings',{method:'PUT',headers,body:JSON.stringify(settings)}); + await page.evaluate(async({source,type})=>{ + const watch=window.app.pages.watch;window.app.navigateTo('watch'); + watch.content={type};watch.resumeTime=0;await watch.loadVideo(source); + },{source,type}); + await page.waitForFunction(()=>window.app.pages.watch.currentSessionId&&window.app.pages.watch.video.readyState>=2&&window.app.pages.watch.sourceDuration>20); + const original=await page.evaluate(()=>({session:window.app.pages.watch.currentSessionId,total:window.app.pages.watch.getPlaybackDuration()})); + assert.ok(original.total>23&&original.total<26,JSON.stringify(original)); + assert.equal(requests.at(-1).videoMode,'copy');assert.equal(requests.at(-1).isLive,false); + const pixel=()=>page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=1;canvas.getContext('2d').drawImage(window.app.pages.watch.video,0,0,1,1);return Array.from(canvas.getContext('2d').getImageData(0,0,1,1).data);}); + let color=await pixel();assert.ok(color[0]>color[2]+100,JSON.stringify(color)); + await page.evaluate(()=>window.app.pages.watch.seekToContentTime(16)); + await page.waitForFunction(old=>window.app.pages.watch.currentSessionId!==old&&window.app.pages.watch.video.readyState>=2,original.session); + color=await pixel();assert.ok(color[2]>color[0]+100,JSON.stringify(color)); + assert.equal(requests.at(-1).seekOffset,16); + assert.equal(await page.evaluate(()=>window.app.pages.watch.getPlaybackDuration()),original.total); + const forward=await page.evaluate(()=>window.app.pages.watch.currentSessionId); + assert.equal((await fixture.request(`/api/transcode/${original.session}/stream.m3u8`,{headers})).status,404); + await page.evaluate(()=>window.app.pages.watch.seekToContentTime(4)); + await page.waitForFunction(old=>window.app.pages.watch.currentSessionId!==old&&window.app.pages.watch.video.readyState>=2,forward); + color=await pixel();assert.ok(color[0]>color[2]+100,JSON.stringify(color)); + assert.equal(requests.at(-1).seekOffset,4); + assert.equal(await page.evaluate(()=>window.app.pages.watch.getPlaybackDuration()),original.total); + await page.evaluate(()=>window.app.pages.watch.stop()); + } + assert.deepEqual(errors,[]);await page.close(); + }); diff --git a/test/enigma2-playback.test.js b/test/enigma2-playback.test.js index 352549b..97c9537 100644 --- a/test/enigma2-playback.test.js +++ b/test/enigma2-playback.test.js @@ -47,7 +47,7 @@ for (const name of ['VideoPlayer', 'WatchPage']) { for (const { url } of f.requests.filter(request => request.url.startsWith('/api/probe'))) { assert.equal(new URL(url, 'http://app').searchParams.get('program'), '102'); } - if (mode === 'forceTranscode' || mode === 'forceVideoTranscode') { + if (name === 'WatchPage' || mode === 'forceTranscode' || mode === 'forceVideoTranscode') { assert.equal(f.sessions.length, 1); assert.equal(f.sessions[0].options.program, '102'); } else { @@ -258,7 +258,7 @@ for (const name of ['VideoPlayer', 'WatchPage']) { if (replace) { assert.equal(probes.length, 2); assert.equal(new URL(probes[1].url, 'http://app').searchParams.get('requestSequence'), '2'); - assert.equal(mode === 'forceTranscode' ? f.sessions.length : Number(f.player.video.src !== ''), 1); + assert.equal(name === 'WatchPage' || mode === 'forceTranscode' ? f.sessions.length : Number(f.player.video.src !== ''), 1); } else { assert.equal(f.sessions.length, 0); assert.equal(f.player.video.src, ''); } } }); diff --git a/test/mobile-layout.test.js b/test/mobile-layout.test.js new file mode 100644 index 0000000..c0dc7b8 --- /dev/null +++ b/test/mobile-layout.test.js @@ -0,0 +1,49 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),http=require('node:http'); +for (const engine of ['chromium','webkit']) { + test(`${engine} keeps mobile player controls visible and clickable through viewport changes`, + { skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 45000 }, async t => { + const type=require('playwright')[engine],root=path.join(__dirname,'../public'); + const scripts=['/js/pages/WatchPage.js','/js/pages/SeriesPage.js']; + const html=fs.readFileSync(path.join(root,'index.html'),'utf8').replace(/]*>[\s\S]*?<\/script>/gi,'').replace(/]*https:\/\/[^>]*>/gi,''); + const server=http.createServer((req,res)=>{ + const name=new URL(req.url,'http://fixture').pathname; + if(name==='/'){res.setHeader('Content-Type','text/html');res.end(html+scripts.map(src=>``).join(''));return;} + if(name==='/css/main.css'||scripts.includes(name)){res.setHeader('Content-Type',name.endsWith('.css')?'text/css':'text/javascript');res.end(fs.readFileSync(path.join(root,name)));return;} + res.writeHead(404);res.end(); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + t.after(()=>new Promise(resolve=>{server.close(resolve);server.closeAllConnections();})); + const browser=await type.launch({headless:true,executablePath:engine==='chromium'?process.env.NODECAST_BROWSER_EXECUTABLE||undefined:undefined});t.after(()=>browser.close()); + const page=await browser.newPage({viewport:{width:390,height:844},userAgent:'Mozilla/5.0 (iPhone; CPU iPhone OS 26_3 like Mac OS X) AppleWebKit/605.1.15 Version/26.3 Mobile/15E148 Safari/604.1'}); + const errors=[];page.on('pageerror',error=>errors.push(error.message));await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(()=>{ + window.fullscreenClicks=0;window.PlayerFullscreen={toggle(){window.fullscreenClicks++;}}; + window.watch=new WatchPage({}); + for(const node of document.querySelectorAll('.page'))node.classList.remove('active'); + document.querySelector('#page-watch').classList.add('active'); + }); + for(const viewport of [{width:390,height:844},{width:390,height:640},{width:844,height:390},{width:568,height:256},{width:390,height:844}]){ + await page.setViewportSize(viewport); + await page.waitForFunction(()=>{ + const r=document.querySelector('.watch-video-section').getBoundingClientRect(); + return r.bottom<=innerHeight+1; + }); + const bounds=await page.evaluate(()=>{ + const r=document.querySelector('#watch-fullscreen').getBoundingClientRect(); + const point=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2); + return {top:r.top,bottom:r.bottom,left:r.left,right:r.right,h:innerHeight,w:innerWidth,hit:point?.closest('button')?.id}; + }); + assert.ok(bounds.top>=0&&bounds.bottom<=bounds.h&&bounds.left>=0&&bounds.right<=bounds.w,JSON.stringify(bounds)); + assert.equal(bounds.hit,'watch-fullscreen');await page.locator('#watch-fullscreen').click(); + } + assert.equal(await page.evaluate(()=>window.fullscreenClicks),5); + // Current WebKit series rendering is checked, but does not establish Safari 26.3's report as fixed. + await page.evaluate(async()=>{ + document.querySelector('#page-watch').classList.remove('active');document.querySelector('#page-series').classList.add('active'); + window.Icons={chevronDown:'v'};window.API={proxy:{xtream:{seriesInfo:async()=>({episodes:{1:[{id:'1',episode_num:1,title:'Fixture Episode'}]}})}}}; + const series=new SeriesPage({});await series.showSeriesDetails({series_id:1,sourceId:1,name:'Fixture'}); + }); + await page.locator('.episode-item').scrollIntoViewIfNeeded();assert.equal(await page.locator('.episode-item').isVisible(),true); + assert.deepEqual(errors,[]);await page.close(); + }); +} diff --git a/test/playback-lifecycle.test.js b/test/playback-lifecycle.test.js index 9bb658c..81ff729 100644 --- a/test/playback-lifecycle.test.js +++ b/test/playback-lifecycle.test.js @@ -262,7 +262,7 @@ for (const name of ['VideoPlayer', 'WatchPage']) { test(`${name} ignores callbacks from an obsolete HLS instance`, () => { const window = {}; class Hls extends EventEmitter { - static Events = { MANIFEST_PARSED: 'manifest', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch', NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues', FRAG_CHANGED: 'fragment', DESTROYING: 'destroying', MEDIA_ATTACHED: 'attached', MANIFEST_LOADING: 'manifest-loading' }; + static Events = { MANIFEST_PARSED: 'manifest', LEVEL_LOADED: 'level', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch', NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues', FRAG_CHANGED: 'fragment', DESTROYING: 'destroying', MEDIA_ATTACHED: 'attached', MANIFEST_LOADING: 'manifest-loading' }; static ErrorTypes = { NETWORK_ERROR: 'network', MEDIA_ERROR: 'media' }; loadSource() {} attachMedia() {} destroy() { this.destroyed = true; } } diff --git a/test/remux-playback.test.js b/test/remux-playback.test.js index 83e9a87..489d607 100644 --- a/test/remux-playback.test.js +++ b/test/remux-playback.test.js @@ -30,6 +30,9 @@ function playerFixture(name, settings, probe) { const element = { classList: { add() {}, remove() {} } }; Object.assign(player, { settings, video: { src: '', play: async () => {}, querySelectorAll: () => [] }, overlay: element, nowPlaying: element, + sessions: [], + async startTranscodeSession(url, options) { this.sessions.push({ url, options }); return '/api/session/stream.m3u8'; }, + playHls(url) { this.video.src = url; }, stop() { this.playbackGeneration = (this.playbackGeneration || 0) + 1; this.video.src = ''; }, updateTranscodeStatus() {}, updateQualityBadge() {}, updateNowPlaying() {}, showNowPlayingOverlay() {}, fetchEpgData() {}, setVolumeFromStorage() {}, showLoading() {}, @@ -41,7 +44,7 @@ function playerFixture(name, settings, probe) { for (const name of ['VideoPlayer', 'WatchPage']) { for (const mode of ['autoTranscode', 'forceRemux']) { - test(`${name} ${mode} propagates mixed audio codecs to the remux request`, async () => { + test(`${name} ${mode} preserves probe metadata when choosing remux playback`, async () => { const requests = []; const player = playerFixture(name, { [mode]: true }, async url => { requests.push(url); @@ -49,6 +52,15 @@ for (const name of ['VideoPlayer', 'WatchPage']) { }); const url = 'http://provider.test/media.ts?password=abc/'; await player.start(url); + if (name === 'WatchPage') { + assert.equal(player.sessions.length, 1); + assert.equal(player.sessions[0].url, url); + assert.equal(player.sessions[0].options.videoMode, 'copy'); + assert.equal(player.sessions[0].options.audioCodec, 'mp3'); + assert.equal(player.video.src, '/api/session/stream.m3u8'); + assert.equal(requests.length, 1); + return; + } const remux = new URL(player.video.src, 'http://app.test'); assert.equal(remux.pathname, '/api/remux'); assert.equal(remux.searchParams.get('url'), url); diff --git a/test/vod-subtitles.test.js b/test/vod-subtitles.test.js index 4637508..9e01ff0 100644 --- a/test/vod-subtitles.test.js +++ b/test/vod-subtitles.test.js @@ -83,7 +83,6 @@ test('Watch VOD probes expose real embedded subtitles for HLS and remux, align s await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French early'))); await page.evaluate(() => window.watch.seekToContentTime(6)); await page.waitForFunction(() => window.watch.hls?.url?.endsWith('/seek.m3u8') && document.querySelectorAll('#list button').length === 3); - await page.locator('#list button').nth(2).click(); await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'French late' && cue.startTime > 1.9 && cue.startTime < 2.1))); await page.evaluate(() => { window.watch.video.pause(); window.watch.video.currentTime = 3; }); await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French late')));