Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-27 16:23:48 +07:00
co-authored by Claude Opus 5.5
commit 8cdbfee08f
345 changed files with 20773 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
import { contentPath } from '../routing';
import type { Chapter, Comment, Drama, DramaDetail, PageData } from "../api/types";
import type { SiteContext } from "./context";
import { empty, escape } from "./render";
import { configureDownloadLinks } from "./download";
import { bindSeriesDescription } from "./detail";
import { bindChapterUnlock } from "./unlock";
import { chapterAccess, chapterUnlockCost, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { dramaSeoContent } from '../seo';
import type { PlayerInitialData } from '../page-data';
import { commentListMarkup, 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 {page?:number;commentSort?:number;pageHref?:(page:number,sort:number)=>string;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:6},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 unlockCost=chapterUnlockCost(chapter);
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);
const commentPage=paging.page||1,commentSort=paging.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapterId?'play':'detail',raw,chapterId);
const pageHref=paging.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
app.updateSeo?.({...dramaSeoContent(raw,chapter||undefined),failed:false,unavailable:isVideo&&!source});
const objectId=isVideo?id:chapter?.id||id, objectType=isVideo?20:chapter?11:10;
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,page:commentPage,commentSort,pageHref,config:app.config,home:app.home,user:app.api.user,comments:paging.initial?.comments});
configureDownloadLinks(app.config, page);
window.tracker?.setDrama({...raw,playletChapterInfo:ordered},chapter||first,ordered,isVideo);
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);
}
renderEpisodes();
window.addEventListener('resize',renderEpisodes,{signal});
let commentSeq=0,replyParent=0,replyId=0;
let initialComments=paging.initial?.comments;
async function comments(){
const seq=++commentSeq;
const list=page.querySelector<HTMLElement>('.play-comment-list')!;
try{
const existing=initialComments;initialComments=undefined;
const data=existing || await app.api.get<PageData<Comment>>('comment/list',{objectId,objectType,pageNum:commentPage,pageSize:10,sortOrder:commentSort},signal);
if(signal.aborted||seq!==commentSeq)return;
app.updateSeo?.({totalCount:data.totalCount,pageSize:10});
page.querySelector('#play-comments-title')!.textContent=`评论 (${data.totalCount||0})`;
if(!(existing&&paging.preserveMarkup))list.innerHTML=commentListMarkup(data,commentPage,commentSort,pageHref);
}catch(error){if(!signal.aborted&&seq===commentSeq)list.innerHTML=empty(error instanceof Error?error.message:'评论加载失败');}
}
void comments();
const video=page.querySelector<HTMLVideoElement>('video');
const chapterUnlock=bindChapterUnlock(app,id,chapter,raw.slug);
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:()=>{window.tracker?.playbackFailed(video);box.dispatchEvent(new Event('playback:error'));}});
const toggle=async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}window.tracker?.prepareDramaModeChange();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;
const unlock=document.createElement('button');unlock.type='button';unlock.className='primary';unlock.dataset.showUnlock='';
unlock.textContent=access.gate==='vip'?'查看会员套餐':`使用 ${unlockCost} 金币解锁`;
box.querySelector('.play-screen-overlay')!.append(unlock);
function setMode(nextMode:PlayMode){
window.tracker?.prepareDramaModeChange();
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;
unlock.hidden=!blocked||!['coins','vip'].includes(access.gate);
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=access.gate==='vip'?'开通会员后可观看本集':access.gate==='coins'?'本集需要金币解锁':'本集暂不可观看';
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.lockedChapter){const selected=ordered.find(item=>item.id===Number(element.dataset.lockedChapter));if(selected)await chapterUnlock.openChapter(selected);return;}
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
if(element.hasAttribute('data-show-unlock'))chapterUnlock.openCurrent();
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){window.tracker?.dramaStatusChange(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){window.tracker?.dramaEvent('ep_share');app.notify('页面链接已复制');}}finally{finish();}return;}
if(element.dataset.commentSort){const sort=Number(element.dataset.commentSort);if(sort===1||sort===2)app.navigate(pageHref(1,sort));return;}
if(element.dataset.reply){replyId=Number(element.dataset.reply);replyParent=Number(element.dataset.parent);page.querySelector('#reply-context')!.innerHTML=`<div class="play-reply-context"><span>回复 @${escape(element.dataset.replyName)}</span><button type="button" data-cancel-reply>取消回复</button></div>`;page.querySelector<HTMLTextAreaElement>('textarea')!.focus();}
if(element.hasAttribute('data-cancel-reply')){replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';}
if(element.dataset.commentLike){
const active=element.getAttribute('aria-pressed')==='true',state={isLike:active?1:0,countLike:Number(element.dataset.commentCount)||0};
const finish=beginButtonRequest(element);
try{
const result=await app.api.post<unknown>('comment/like/set',{commentId:Number(element.dataset.commentLike),isLike:!active});
if(signal.aborted)return;
applyEngagementSuccess(state,'like',active,result);
element.setAttribute('aria-pressed',String(state.isLike===1));element.dataset.commentCount=String(state.countLike);
const label=element.querySelector('[data-request-label]')||element;label.textContent=`${state.isLike===1?'♥':'♡'} ${formatCount(state.countLike)}`;
}finally{finish();}
}
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
page.querySelector('textarea[name="comment"]')?.addEventListener('input',event=>{const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent=String((event.target as HTMLTextAreaElement).value.length);},{signal});
page.querySelector('form')!.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;const input=form.elements.namedItem('comment') as HTMLTextAreaElement;const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
if(button.disabled||!input.value.trim())return;
const finish=beginButtonRequest(button,'发送中');
void app.api.post('comment/send',{objectId,objectType,text:input.value.trim(),parentsId:replyParent,replyCommentId:replyId}).then(async()=>{if(signal.aborted)return;input.value='';const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent='0';replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';page.querySelector('#comment-error')!.textContent='';if(commentPage>1){app.navigate(pageHref(1,commentSort));return;}await comments();}).catch(error=>{if(!signal.aborted)page.querySelector('#comment-error')!.textContent=error.message;}).finally(finish);
},{signal});
return ()=>{disposeDescription();chapterUnlock.dispose();media?.dispose();};
}