Files
web_hgcg_h5/src/lib/client/player.ts
T
2026-10-27 17:39:27 +07:00

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();};
}