Keep mobile controls visible and make recorded VOD remux seekable
This commit is contained in:
@ -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();
|
||||
});
|
||||
|
||||
@ -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, ''); }
|
||||
}
|
||||
});
|
||||
|
||||
49
test/mobile-layout.test.js
Normal file
49
test/mobile-layout.test.js
Normal file
@ -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(/<script\b[^>]*>[\s\S]*?<\/script>/gi,'').replace(/<link\b[^>]*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=>`<script src="${src}"></script>`).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();
|
||||
});
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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')));
|
||||
|
||||
Reference in New Issue
Block a user