139 lines
9.7 KiB
TypeScript
139 lines
9.7 KiB
TypeScript
import { contentPath } from '../routing';
|
|
import type { Chapter, Drama, DramaDetail, PageData } from "../api/types";
|
|
import type { SiteContext } from "./context";
|
|
import { configureDownloadLinks } from "./download";
|
|
import { bindSeriesDescription } from "./detail";
|
|
import { chapterAccess, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
|
|
import { dramaSeoContent } from '../seo';
|
|
import type { PlayerInitialData } from '../page-data';
|
|
import { episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
|
|
import { formatCount } from '../api/protocol';
|
|
import { applyEngagementSuccess, beginButtonRequest, type EngagementKind } from './request-feedback';
|
|
|
|
declare global { interface Window { installPlayerControls?: (screen: HTMLElement, episode?: number, actions?: PlayerActions)=>void } }
|
|
|
|
interface PlayerPagination {initial?:PlayerInitialData;preserveMarkup?:boolean}
|
|
|
|
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,paging:PlayerPagination={}):Promise<()=>void> {
|
|
if(isVideo)throw new Error('当前接口暂未提供剧集内容');
|
|
const signal=app.signal;
|
|
const page=document.getElementById('drama-play-page')!;
|
|
const raw = paging.initial?.detail || await app.api.get<DramaDetail>('playlet/detail',{id},signal);
|
|
if(signal.aborted)return ()=>{};
|
|
if(!raw?.id||raw.id!==id)throw new Error('该剧集不存在或已下架');
|
|
const [chapters,related]=await Promise.all([
|
|
paging.initial?Promise.resolve(paging.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
|
|
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:12},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
|
|
]);
|
|
if(!paging.initial)raw.relatedRecommendData=related?.list||[];
|
|
if(signal.aborted)return ()=>{};
|
|
const ordered=orderedChapters(chapters ?? raw.playletChapterInfo);
|
|
const first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
|
|
let chapter: Chapter | null = null;
|
|
if(chapterId){
|
|
if(ordered.length&&!ordered.some(item=>item.id===chapterId))throw new Error('该章节不存在或已下架');
|
|
chapter=paging.initial?.chapter || await app.api.get<Chapter>('playlet/chapter/detail',{playletId:id,chapterId},signal);
|
|
if(!signal.aborted&&(!chapter||chapter.id!==chapterId||chapter.playletId&&chapter.playletId!==id))throw new Error('章节信息不匹配,请重新选择');
|
|
}
|
|
if(signal.aborted)return ()=>{};
|
|
const overview=!chapterId&&!isVideo;
|
|
const source=isVideo?playbackUrl(raw.video?.url):'';
|
|
const access=isVideo?{allowed:!!source,gate:source?'':'unavailable',source} as const:chapterAccess(chapter);
|
|
const previewSource=isVideo?'':playbackUrl(raw.preview);
|
|
let mode:PlayMode=initialPlayMode(access,raw.preview);
|
|
page.classList.toggle('is-overview',overview);
|
|
app.updateSeo?.({...dramaSeoContent(raw,chapter||undefined),failed:false,unavailable:isVideo&&!source});
|
|
const objectId=isVideo?id:chapter?.id||id;
|
|
const actionPrefix=isVideo?'video':chapter?'playlet/chapter':'playlet';
|
|
const actionState=chapter||raw;
|
|
const pendingEngagements=new Set<EngagementKind>();
|
|
function syncEngagement(kind:EngagementKind,buttons:HTMLButtonElement[]){
|
|
const active=kind==='like'?actionState.isLike===1:actionState.isCollect===1;
|
|
buttons.forEach(button=>{
|
|
button.setAttribute('aria-pressed',String(active));
|
|
const label=button.querySelector('span');if(label)label.textContent=kind==='like'?(active?'已点赞':'点赞'):(active?'已收藏':'收藏');
|
|
});
|
|
const value=kind==='like'?actionState.countLike:actionState.countCollect;
|
|
page.querySelectorAll<HTMLElement>(`[data-engage-count="${kind}"]`).forEach(node=>{node.textContent=`${formatCount(value)}${kind==='like'?'点赞':'收藏'}`;});
|
|
}
|
|
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,home:app.home});
|
|
configureDownloadLinks(app.config, page);
|
|
const disposeDescription=bindSeriesDescription(page,signal);
|
|
let episodeSize=matchMedia('(max-width:760px)').matches?15:45;
|
|
let episodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter!.id))/episodeSize):0;
|
|
function renderEpisodes(){
|
|
const size=matchMedia('(max-width:760px)').matches?15:45;
|
|
if(size!==episodeSize){episodePage=Math.floor(episodePage*episodeSize/size);episodeSize=size;}
|
|
const pages=Math.ceil(ordered.length/size);
|
|
episodePage=Math.min(Math.max(0,episodePage),Math.max(0,pages-1));
|
|
const segment=page.querySelector('.play-episode-pages'),grid=page.querySelector('.play-episode-grid');
|
|
if(!grid||!segment)return;
|
|
segment.innerHTML=episodeTabs(ordered,size,episodePage);
|
|
const track=segment.querySelector<HTMLElement>('.episode-page-track'),selected=segment.querySelector<HTMLElement>('[aria-pressed="true"]');
|
|
if(track&&selected){
|
|
const bounds=track.getBoundingClientRect(),tab=selected.getBoundingClientRect();
|
|
if(tab.left<bounds.left)track.scrollLeft+=tab.left-bounds.left;
|
|
else if(tab.right>bounds.right)track.scrollLeft+=tab.right-bounds.right;
|
|
}
|
|
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug,raw.title||raw.playName||'');
|
|
}
|
|
renderEpisodes();
|
|
window.addEventListener('resize',renderEpisodes,{signal});
|
|
const video=page.querySelector<HTMLVideoElement>('video');
|
|
let media:MediaPlayback|undefined;
|
|
let reported=false;
|
|
if(video){
|
|
const box=page.querySelector<HTMLElement>('.play-screen')!;
|
|
const status=page.querySelector<HTMLElement>('.play-source-status')!;
|
|
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{box.dispatchEvent(new Event('playback:error'));}});
|
|
const toggle=async()=>{if(mode==='main'&&!access.allowed)return;await media?.toggle();};
|
|
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed)return;await media?.retry();}});
|
|
const position=ordered.findIndex(item=>item.id===chapter?.id);
|
|
const previous=box.querySelector<HTMLButtonElement>('[data-play-action="previous"]');
|
|
const next=box.querySelector<HTMLButtonElement>('[data-play-action="next"]');
|
|
if(previous)previous.disabled=position<=0;if(next)next.disabled=position<0||position>=ordered.length-1;
|
|
function setMode(nextMode:PlayMode){
|
|
mode=nextMode;box.dataset.playMode=mode;
|
|
page.querySelectorAll<HTMLButtonElement>('[data-play-mode]').forEach(button=>{if(button.tagName==='BUTTON')button.setAttribute('aria-pressed',String(button.dataset.playMode===mode));});
|
|
const blocked=mode==='main'&&!access.allowed;
|
|
box.querySelector('.ui-empty-state')?.remove();
|
|
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=false;
|
|
status.hidden=false;
|
|
void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:'');
|
|
if(blocked)status.textContent='本集视频暂未开放,请稍后再试';
|
|
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
|
|
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
|
|
}
|
|
page.querySelectorAll<HTMLButtonElement>('button[data-play-mode]').forEach(button=>button.addEventListener('click',()=>{setMode(button.dataset.playMode as PlayMode);if(mode==='preview')void media!.toggle();},{signal}));
|
|
setMode(mode);
|
|
document.addEventListener('eighteen:closed',()=>{if(mode==='preview'&&video.paused)void media!.toggle();},{once:true,signal});
|
|
video.addEventListener('playing',()=>{
|
|
if(reported||mode!=='main'||!access.allowed||!chapter)return;
|
|
reported=true;
|
|
void app.api.post('playlet/chapter/watch/report',{playletId:id,chapterId:chapter.id}).catch(()=>{reported=false;});
|
|
},{signal});
|
|
}
|
|
page.addEventListener('click',event=>{void (async()=>{
|
|
const element=(event.target as Element).closest<HTMLButtonElement>('button');if(!element||element.disabled)return;
|
|
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
|
|
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
|
|
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){app.navigate(contentPath('play',raw,next.id));}}
|
|
if(element.dataset.engage){
|
|
const kind=element.dataset.engage;if(kind!=='like'&&kind!=='collect'||pendingEngagements.has(kind))return;
|
|
const active=kind==='like'?actionState.isLike===1:actionState.isCollect===1;
|
|
const buttons=Array.from(page.querySelectorAll<HTMLButtonElement>(`[data-engage="${kind}"]`));
|
|
if(!buttons.length)buttons.push(element);
|
|
const finish=buttons.map(button=>beginButtonRequest(button));
|
|
pendingEngagements.add(kind);
|
|
try{
|
|
const result=await app.api.post<unknown>(`${actionPrefix}/${kind}/set`,{[chapter?'chapterId':'id']:objectId,[kind==='like'?'isLike':'isCollect']:!active});
|
|
if(signal.aborted)return;
|
|
applyEngagementSuccess(actionState,kind,active,result);syncEngagement(kind,buttons);
|
|
}finally{pendingEngagements.delete(kind);finish.forEach(done=>done());}
|
|
return;
|
|
}
|
|
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted)app.notify('页面链接已复制');}finally{finish();}return;}
|
|
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
|
|
return ()=>{disposeDescription();media?.dispose();};
|
|
}
|