// @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>=[]; 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('[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>=[]; 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('.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('[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('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>=[]; 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>=[]; 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'}); });