Files
reader/web/test/media-native.test.ts

114 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { MediaPlayer } from '../src/features/media/services/player.ts';
import type { MediaApi, Playback } from '../src/features/media/api/media-api.ts';
afterEach(()=>{delete window.ReaderMedia;vi.restoreAllMocks();});
it('uses discovered clipped duration when scan duration is absent, and bounds seeks without retaining the previous item duration',async()=>{
const messages:Array<Record<string,unknown>>=[];
const port={postMessage:(text:string)=>messages.push(JSON.parse(text)),onmessage:null as ((event:{data:string})=>void)|null};window.ReaderMedia=port;
vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(()=>{});vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});
const player=new MediaPlayer({nativeCredentials:()=>({userId:'user',baseUrl:'https://server',accessToken:'token'})} as unknown as MediaApi);
player.reconnectNative();
const state={sessionId:'first',queueId:'queue',userId:'user',baseUrl:'https://server',partId:'a',start:30,end:null,position:45,paused:true,error:''};
port.onmessage!({data:JSON.stringify(state)});
const seek=player.element.querySelector<HTMLInputElement>('[aria-label="当前章节播放位置"]')!;
expect(seek.disabled).toBe(true);
port.onmessage!({data:JSON.stringify({...state,duration:90})});
expect(player.element.querySelector('.media-mini-time')!.textContent).toBe('00:15 / 01:30');
expect(seek.disabled).toBe(false);expect(seek.max).toBe('90');
seek.value='60';seek.dispatchEvent(new Event('change'));expect(messages.at(-1)).toEqual({action:'seek',position:90});
player.seek(200);expect(messages.at(-1)).toEqual({action:'seek',position:120});
port.onmessage!({data:JSON.stringify({...state,sessionId:'second',partId:'b',start:0,position:0,duration:-9223372036854776})});
expect(seek.disabled).toBe(true);expect(player.element.querySelector('.media-mini-time')!.textContent).toBe('00:00 / 时长未知');
await player.stop();
});
it.each([false,true])('rebinds to the matching account without creating a session or changing reading visibility (video=%s)',async(video)=>{
const messages:Array<Record<string,unknown>>=[];
const port={postMessage:(text:string)=>messages.push(JSON.parse(text)),onmessage:null as ((event:{data:string})=>void)|null};
window.ReaderMedia=port;
vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(()=>{});
vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});
const playback=vi.fn(),request=vi.fn();
const api={nativeCredentials:()=>({userId:'user',baseUrl:'https://server',accessToken:'new-token'}),playback,request} as unknown as MediaApi;
const player=new MediaPlayer(api);player.setVisible(false);player.reconnectNative();
port.onmessage!({data:JSON.stringify({video,sessionId:'existing',userId:'user',baseUrl:'https://server',queueId:'queue',partId:'part',title:'正在听的书',start:30,end:120,position:75,paused:false,playing:true,ended:false,error:''})});
expect(player.canOpenNativeVideo).toBe(video);
expect(player.element.classList.contains('media-player-native-video')).toBe(video);
expect(player.element.querySelector<HTMLElement>('.media-audio-controls')!.hidden).toBe(false);
expect(player.element.querySelector('[aria-label="播放位置"]')).toBeNull();
expect(player.active).toBe(true);expect(player.title).toBe('正在听的书');expect(player.paused).toBe(false);
const chapterSeek=player.element.querySelector<HTMLInputElement>('[aria-label="当前章节播放位置"]')!;
expect(chapterSeek.max).toBe('90');expect(chapterSeek.value).toBe('45');
chapterSeek.value='60';chapterSeek.dispatchEvent(new Event('change'));
expect(messages.at(-1)).toEqual({action:'seek',position:90});
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',queueIndex:0,currentQueue:[{title:'第一章'},{title:'第二章'},{title:'第三章'}],position:75,paused:false,error:''})});
expect(player.currentPlaylist).toHaveLength(3);await player.selectPlaylist(2);
expect(messages.at(-1)).toEqual({action:'selectQueue',sessionId:'existing',index:2});expect(playback).not.toHaveBeenCalled();
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',queueIndex:0,queueRevision:7,position:75,paused:false,error:''})});
player.editPlaylist(2,0);
expect(messages.at(-1)).toEqual({action:'editQueue',sessionId:'existing',revision:7,index:2,direction:0});
expect(player.currentPlaylist).toHaveLength(3);
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',queueIndex:0,position:76,paused:false,error:''})});
expect(player.currentPlaylist).toHaveLength(3);
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',queueIndex:0,currentQueue:[null],position:77,paused:false,error:''})});
expect(player.currentPlaylist).toHaveLength(3);
const audioTracks=[{id:'0:0',groupId:'g',label:'国语',supported:true,selected:true},{id:'0:1',groupId:'g',label:'英语',supported:true,selected:false},{id:'1:0',groupId:'other',label:'其他',supported:false,selected:false}];
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',position:75,paused:false,audioTracks,tracksVersion:4,error:''})});
const trackSelect=player.element.querySelector<HTMLSelectElement>('select[aria-label="原生音轨"]')!;
expect(trackSelect.options).toHaveLength(3);expect(trackSelect.options[2]!.disabled).toBe(true);
trackSelect.value='0:1';trackSelect.dispatchEvent(new Event('change'));
expect(messages.at(-1)).toEqual({action:'audioTrack',sessionId:'existing',id:'0:1',groupId:'g',tracksVersion:4});
const messageCount=messages.length;trackSelect.value='1:0';trackSelect.dispatchEvent(new Event('change'));expect(messages).toHaveLength(messageCount);
expect(playback).not.toHaveBeenCalled();expect(player.element.hidden).toBe(true);
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',position:75,paused:false,canPrevious:true,canNext:true,error:''})});
expect(player.canPrevious).toBe(true);expect(player.canNext).toBe(true);
await player.move(-1);expect(messages.at(-1)).toEqual({action:'previous'});
await player.move(1);expect(messages.at(-1)).toEqual({action:'next'});
port.onmessage!({data:JSON.stringify({sessionId:'existing',queueId:'queue',position:75,paused:true,canPrevious:false,canNext:false,queueSwitching:true,error:''})});
expect(player.canNext).toBe(false);expect(player.hasQueueNavigation).toBe(true);
expect(player.element.hidden).toBe(true);expect(playback).not.toHaveBeenCalled();await player.flush();expect(request).not.toHaveBeenCalled();
await player.stop();
player.reconnectNative();await player.stop();
port.onmessage!({data:JSON.stringify({sessionId:'existing',userId:'user',baseUrl:'https://server',queueId:'queue',partId:'part',start:30,end:120,position:75,paused:false})});
expect(player.active).toBe(false);
});
it('rejects a native session belonging to another account',async()=>{
const messages:Array<Record<string,unknown>>=[];
const port={postMessage:(text:string)=>messages.push(JSON.parse(text)),onmessage:null as ((event:{data:string})=>void)|null};
window.ReaderMedia=port;
const player=new MediaPlayer({nativeCredentials:()=>({userId:'new-user',baseUrl:'https://server',accessToken:'token'})} as unknown as MediaApi);
player.reconnectNative();port.onmessage!({data:JSON.stringify({sessionId:'old',userId:'old-user',baseUrl:'https://server',position:20,paused:false})});
expect(player.active).toBe(false);expect(messages.at(-1)).toEqual({action:'stop'});
});
it.each([false,true])('delegates media and persistence to Android (video=%s), retaining controls and stopping on logout',async(video)=>{
const messages:Array<Record<string,unknown>>=[];
const port={postMessage:(text:string)=>messages.push(JSON.parse(text)),onmessage:null as ((event:{data:string})=>void)|null};
window.ReaderMedia=port;
const play=vi.spyOn(HTMLMediaElement.prototype,'play').mockResolvedValue();
vi.spyOn(HTMLMediaElement.prototype,'pause').mockImplementation(()=>{});
vi.spyOn(HTMLMediaElement.prototype,'load').mockImplementation(()=>{});
const plan:Playback={id:'native-session',partId:'part',streamUrl:'/api/v1/media/streams/native-session?ticket=fixture',contentType:'audio/mpeg',expiresAt:Date.now()+21600000,start:0,end:90,position:10,revision:2};
const request=vi.fn();
const api={preferenceScope:()=>'native-test',playback:async()=>plan,streamUrl:()=> 'http://localhost'+plan.streamUrl,nativePlan:()=>({...plan,baseUrl:'http://localhost',accessToken:'fixture'}),request} as unknown as MediaApi;
const player=new MediaPlayer(api);
await player.play([{part:{id:'part',assetId:'asset',title:'Track',start:0,end:90,available:true},title:'Track',video}],0,video);
expect(play).not.toHaveBeenCalled();
expect(messages[0]).toMatchObject({action:'play',sessionId:'native-session',position:10});
expect(messages[0]!.plan).toMatchObject({video});
expect(player.video.hidden).toBe(true);
expect(player.canOpenNativeVideo).toBe(video);
if(video){await player.openNativeVideo();expect(messages.at(-1)).toEqual({action:'showVideo'});}
const nativePlan=messages[0]!.plan as {queueId:string;queue:Array<{partId:string}>};
expect(nativePlan.queue).toEqual([{partId:'part',title:'Track'}]);
port.onmessage!({data:JSON.stringify({sessionId:'native-session',position:23,paused:false,playing:true,ended:false,error:''})});
await player.flush();expect(request).not.toHaveBeenCalled();expect(player.paused).toBe(false);
port.onmessage!({data:JSON.stringify({sessionId:'next-session',queueId:nativePlan.queueId,queueIndex:1,title:'Next track',partId:'next-part',start:0,end:120,position:2,paused:false,playing:true,ended:false,error:''})});
expect(player.title).toBe('Next track');
port.onmessage!({data:JSON.stringify({sessionId:'next-session',queueId:nativePlan.queueId,position:120,paused:false,playing:false,ended:true,error:''})});
expect(messages.filter(message=>message.action==='play')).toHaveLength(1);
player.speed(1.5);player.sleep(30);player.pause();
expect(messages).toContainEqual({action:'speed',value:1.5});expect(messages).toContainEqual({action:'sleep',minutes:30});
await player.stop();expect(messages.at(-1)).toEqual({action:'stop'});
});