mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
feat(media): one more-options entry, distinct lyrics and episode icons
The now-playing title row carried an ellipsis that opened the same sheet as the header (or options-row) ellipsis. Drop it; the header one stays. Lyrics no longer reuse the book icon that means chapters; episodes no longer share the layers icon with versions. The v2 review script measures the remaining more-options button.
This commit is contained in:
1 parent
bf325ec072
commit
fdd3a70401
5 files changed
+27
-8
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import {SleepTimerOptions} from './sleep-timer.tsx';
|
||||
import { ChevronLeft, Layers, Play, Pause, SkipBack, SkipForward, RotateCcw, RotateCw, Square, Timer, Gauge, Ellipsis, BookOpen, ListMusic, Search, ArrowUp, ArrowDown, X, Volume2, Pencil } from 'lucide-react';
|
||||
import { ChevronLeft, Layers, Play, Pause, SkipBack, SkipForward, RotateCcw, RotateCw, Square, Timer, Gauge, Ellipsis, BookOpen, MicVocal, ListMusic, Search, ArrowUp, ArrowDown, X, Volume2, Pencil } from 'lucide-react';
|
||||
import type { MediaPlayer } from '../services/player.ts';
|
||||
import { useEffect, useState, useRef, useLayoutEffect } from 'react';
|
||||
import type {Detail,MediaApi,Part} from '../api/media-api.ts';
|
||||
@@ -15,7 +15,7 @@ import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {PlaybackProblem} from './playback-problem.tsx';
|
||||
|
||||
function PlayingArtwork({api,item,failed,onRetry,player,lyrics,onMore}:{api:MediaApi;item:Detail|null;failed:boolean;onRetry:()=>void;player:MediaPlayer;lyrics:boolean;onMore:()=>void}){
|
||||
function PlayingArtwork({api,item,failed,onRetry,player,lyrics}:{api:MediaApi;item:Detail|null;failed:boolean;onRetry:()=>void;player:MediaPlayer;lyrics:boolean}){
|
||||
const field=(key:string)=>item?.overrides[key]??item?.metadata[key];
|
||||
const edition=item?.editions?.find(entry=>entry.parts.some(part=>part.id===player.currentPartId)),chapter=edition?.parts.find(part=>part.id===player.currentPartId);
|
||||
const book=item?.kind==='audiobook';
|
||||
@@ -24,7 +24,7 @@ function PlayingArtwork({api,item,failed,onRetry,player,lyrics,onMore}:{api:Medi
|
||||
return <>{lyrics?<><div className="media-lyrics-heading"><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}</div><Lyrics key={player.currentPartId} api={api} player={player}/></>:<div className="media-playing-artwork">
|
||||
{!item&&!failed&&<FloatingNotice message="正在读取作品资料…" busy />}{item?<MediaCover api={api} item={item} square={!book} retryable/>:<div className="media-artwork-placeholder" role="status" aria-label={failed?'作品资料暂不可用':'正在读取作品资料'}>{failed?'作品资料暂不可用':null}</div>}
|
||||
{book&&chapter&&<p className="media-playing-chapter">第 {edition!.parts.indexOf(chapter)+1} / {edition!.parts.length} 章 · {item.title}</p>}
|
||||
<div className="media-playing-copy"><div><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}{failed&&<p role="alert">作品资料读取失败。<button onClick={onRetry}>重新读取作品资料</button></p>}</div><button className="media-icon-button" aria-label="当前内容操作" onClick={onMore}><Ellipsis size={20} aria-hidden="true"/></button></div>
|
||||
<div className="media-playing-copy"><div><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}{failed&&<p role="alert">作品资料读取失败。<button onClick={onRetry}>重新读取作品资料</button></p>}</div></div>
|
||||
</div>}</>;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})} backLabel={view==='main'?'返回浏览':'返回播放'}/>}
|
||||
{!player.error&&player.loadingStatus&&<FloatingNotice message={player.loadingStatus} busy />}
|
||||
{player.canOpenNativeVideo&&<button onClick={()=>void player.openNativeVideo()}>打开 Android 视频播放器</button>}
|
||||
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics} onMore={()=>open('more')}/>}
|
||||
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics}/>}
|
||||
{view!=='queue'&&view!=='chapters'&&<div className="media-audio-bottom">
|
||||
{!player.isVideo&&<AudioTimeline player={player}/>}
|
||||
<div className="media-transport">
|
||||
@@ -106,7 +106,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
|
||||
{channel==='music'?<PlaybackFavorite key={itemId} api={api} itemId={itemId} {...(onFavoriteChange?{onChanged:onFavoriteChange}:{})}/>:speed}
|
||||
<button className="media-lyrics-toggle" aria-label="睡眠定时" aria-haspopup="dialog" onClick={()=>open('sleep')}><Timer size={18} aria-hidden="true"/><span>{player.sleepAt?player.sleepRemainingMinutes+' 分钟':'定时'}</span></button>
|
||||
<button className="media-lyrics-toggle" aria-label={channel==='audiobook'?'全部章节':'播放队列'} onClick={()=>setView(channel==='audiobook'?'chapters':'queue')}>{channel==='audiobook'?<BookOpen size={18} aria-hidden="true"/>:<ListMusic size={18} aria-hidden="true"/>}<span>{channel==='audiobook'?'章节':'队列'}</span></button>
|
||||
{channel==='audiobook'?<button className="media-lyrics-toggle" aria-label="播放版本" onClick={()=>open('versions')}><Layers size={18} aria-hidden="true"/><span>版本</span></button>:channel==='music'?<button className="media-lyrics-toggle" aria-label={lyrics?'封面':'歌词'} aria-pressed={lyrics} onClick={()=>setView(lyrics?'main':'lyrics')}><BookOpen size={18} aria-hidden="true"/><span>{lyrics?'封面':'歌词'}</span></button>:null}
|
||||
{channel==='audiobook'?<button className="media-lyrics-toggle" aria-label="播放版本" onClick={()=>open('versions')}><Layers size={18} aria-hidden="true"/><span>版本</span></button>:channel==='music'?<button className="media-lyrics-toggle" aria-label={lyrics?'封面':'歌词'} aria-pressed={lyrics} onClick={()=>setView(lyrics?'main':'lyrics')}><MicVocal size={18} aria-hidden="true"/><span>{lyrics?'封面':'歌词'}</span></button>:null}
|
||||
{!onBack&&<button className="media-lyrics-toggle" aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/></button>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {VideoEpisodeRail} from './video-episode-rail.tsx';
|
||||
import {SleepTimerOptions} from './sleep-timer.tsx';
|
||||
import {Ellipsis,X,Square,Check,Subtitles,AudioLines,Layers} from 'lucide-react';
|
||||
import {Ellipsis,X,Square,Check,Subtitles,AudioLines,Layers,ListVideo} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type {MediaPlayer} from '../services/player.ts';
|
||||
import type {Detail,MediaApi} from '../api/media-api.ts';
|
||||
@@ -44,7 +44,7 @@ export function VideoControls({player,api,onBack}:{player:MediaPlayer;api:MediaA
|
||||
async function run(action:()=>Promise<void>,close=false){if(busy)return;setBusy(true);setNotice('');try{await action();if(close&&!player.error)setTool(null);}catch(error){setNotice(error instanceof Error?error.message:'操作失败,请重试。');}finally{setBusy(false);}}
|
||||
return <section className="media-playback-status media-video-controls" aria-label="播放控制">
|
||||
<div className="media-video-caption"><div><strong>{item?.title||player.title}</strong>{edition&&<small>{edition.label}</small>}</div></div>
|
||||
<div className="media-video-toolbar media-video-tools">{entries.length>1&&<button onClick={()=>open('episodes')}><Layers size={20} aria-hidden="true"/>选集</button>}<button onClick={()=>open('subtitles')}><Subtitles size={20} aria-hidden="true"/>字幕</button><button onClick={()=>open('tracks')}><AudioLines size={20} aria-hidden="true"/>音轨</button><button onClick={()=>open('versions')}><Layers size={20} aria-hidden="true"/>版本</button><button aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/>更多</button></div>
|
||||
<div className="media-video-toolbar media-video-tools">{entries.length>1&&<button onClick={()=>open('episodes')}><ListVideo size={20} aria-hidden="true"/>选集</button>}<button onClick={()=>open('subtitles')}><Subtitles size={20} aria-hidden="true"/>字幕</button><button onClick={()=>open('tracks')}><AudioLines size={20} aria-hidden="true"/>音轨</button><button onClick={()=>open('versions')}><Layers size={20} aria-hidden="true"/>版本</button><button aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/>更多</button></div>
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})}/>}
|
||||
{notice&&<p className="media-error" role="alert">{notice}</p>}
|
||||
{plot&&<details className="media-video-description" open><summary>剧情简介</summary><p>{plot}</p></details>}
|
||||
|
||||
@@ -51,3 +51,16 @@ it('reads uncertain favorite state before allowing another write',async()=>{
|
||||
await vi.waitFor(()=>expect(root.querySelector('[aria-label="取消收藏"]')).not.toBeNull());expect(writes).toBe(1);
|
||||
const signal=request.mock.calls.at(-1)?.[3] as unknown as AbortSignal;act(()=>render(null,root));expect(signal.aborted).toBe(true);
|
||||
});
|
||||
it('offers one more-options entry point and keeps the book icon for chapters only',async()=>{
|
||||
const detail=vi.fn().mockResolvedValue(work('song','track'));
|
||||
const api={detail,request:vi.fn().mockResolvedValue({favorite:false})} as unknown as MediaApi,player={active:true,currentItemId:'song',title:'歌',currentPlaylist:[]} as unknown as MediaPlayer;
|
||||
for(const withBack of [true,false]){
|
||||
await act(async()=>render(<PlaybackControls api={api} player={player} {...(withBack?{onBack:()=>{}}:{})}/>,root));
|
||||
await vi.waitFor(()=>expect(root.querySelector('[data-channel]')?.getAttribute('data-channel')).toBe('music'));
|
||||
// The title-row ellipsis used to open the same sheet as the header/options ellipsis.
|
||||
expect(root.querySelectorAll('[aria-label="更多播放选项"]').length).toBe(1);
|
||||
expect(root.querySelector('[aria-label="当前内容操作"]')).toBeNull();
|
||||
expect(root.querySelector('[aria-label="歌词"] svg')?.classList.contains('lucide-mic-vocal')).toBe(true);
|
||||
await act(async()=>render(null,root));
|
||||
}
|
||||
});
|
||||
@@ -38,3 +38,9 @@ it('opens recovery choices without restarting an expired session automatically',
|
||||
expect(root.textContent).toContain('播放会话已失效');expect(p.play).not.toHaveBeenCalled();
|
||||
act(()=>root.querySelector('button')!.click());expect(versions).toHaveBeenCalledOnce();expect(p.play).not.toHaveBeenCalled();
|
||||
});
|
||||
it('uses different icons for the episode list and the version switcher',async()=>{
|
||||
const p=player();Object.defineProperty(p,'currentPlaylist',{value:[{index:0,title:'第 1 集',current:true},{index:1,title:'第 2 集',current:false}]});
|
||||
await act(async()=>render(<VideoControls api={{detail:vi.fn().mockResolvedValue(film())} as unknown as MediaApi} player={p}/>,root));
|
||||
const icon=(label:string)=>[...root.querySelectorAll('.media-video-toolbar button')].find(button=>button.textContent===label)?.querySelector('svg')?.getAttribute('class')??'';
|
||||
expect(icon('选集')).toContain('lucide-list-video');expect(icon('版本')).toContain('lucide-layers');
|
||||
});
|
||||
@@ -33,7 +33,7 @@ async function saveShot(capture){for(let attempt=0;;attempt++){try{return await
|
||||
const layout=await page.evaluate(()=>{
|
||||
const rect=s=>{const e=document.querySelector(s);if(!e||!e.getClientRects().length)return null;const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height};};
|
||||
const tab=document.querySelector('.media-tabs'),title=document.querySelector('.media-page-heading h1,.media-page-heading h2,.media-audio-heading>strong');
|
||||
return {library:rect('.media-library-filter'),count:rect('.media-browse-tools>span,.media-person-toolbar>span'),tabsScrollbar:tab?getComputedStyle(tab).scrollbarWidth:null,titleSize:title?getComputedStyle(title).fontSize:null,synopsis:rect('.media-detail-description'),chapters:rect('.media-edition'),episodes:rect('.media-episode-grid'),episodeColumns:document.querySelector('.media-episode-grid')?getComputedStyle(document.querySelector('.media-episode-grid')).gridTemplateColumns.split(' ').length:null,episodeLabels:[...document.querySelectorAll('.media-episode-grid>button strong')].map(e=>e.textContent),queue:rect('.media-current-playlist'),contentActions:rect('[aria-label="当前内容操作"]'),stateIcon:rect('.media-state-icon,.media-empty-emblem'),stateTitle:document.querySelector('.media-state-page h2,.media-screen-error.is-page>strong,.media-library-empty h2')?.textContent};
|
||||
return {library:rect('.media-library-filter'),count:rect('.media-browse-tools>span,.media-person-toolbar>span'),tabsScrollbar:tab?getComputedStyle(tab).scrollbarWidth:null,titleSize:title?getComputedStyle(title).fontSize:null,synopsis:rect('.media-detail-description'),chapters:rect('.media-edition'),episodes:rect('.media-episode-grid'),episodeColumns:document.querySelector('.media-episode-grid')?getComputedStyle(document.querySelector('.media-episode-grid')).gridTemplateColumns.split(' ').length:null,episodeLabels:[...document.querySelectorAll('.media-episode-grid>button strong')].map(e=>e.textContent),queue:rect('.media-current-playlist'),contentActions:rect('[aria-label="更多播放选项"]'),stateIcon:rect('.media-state-icon,.media-empty-emblem'),stateTitle:document.querySelector('.media-state-page h2,.media-screen-error.is-page>strong,.media-library-empty h2')?.textContent};
|
||||
});
|
||||
if(layout.library&&layout.count)assert.ok(layout.library.x+layout.library.width<=layout.count.x+1,key+' library filter must be left of count');
|
||||
if(layout.tabsScrollbar)assert.equal(layout.tabsScrollbar,'none',key+' tabs must hide scrollbars');
|
||||
|
||||
Reference in new issue
Block a user