Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-11-05 23:07:59 +07:00
co-authored by Claude Opus 5.5
commit f65d10e998
377 changed files with 31301 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import { contentPath, contentSegment } from '../routing';
import type { Chapter, Comment, Config, Drama, DramaDetail, HomeData, PageData, User } from '../api/types';
import { formatCount, recordList } from '../api/protocol';
import { ads, card, empty, escape, imageAttributes, pagination, promotionRow, title } from './render';
import { detailActionIcon, officialLinks, overviewMarkup } from './detail';
import { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
/** Local guest avatar used while encrypted CDN avatars decrypt. */
export const COMMENT_AVATAR_FALLBACK='/assets/8837c3e58159bd48.png';
export interface PlayerViewOptions {
chapter?:Chapter|null; isVideo?:boolean; page?:number; commentSort?:number;
comments?:PageData<Comment>; config?:Config; home?:HomeData; user?:User|null;
pageHref?:(page:number,sort:number)=>string;
}
export function orderedChapters(value:unknown):Chapter[] {
return recordList<Chapter>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0));
}
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
const {chapter=null,isVideo=false,comments,config={},home={},user}=options;
const id=raw.id,overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
const displayTitle=title(raw),heading=chapter?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle,liked=raw.isLike===1,collected=raw.isCollect===1;
const commentPage=options.page||1,commentSort=options.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapter?'play':'detail',raw,chapter?.id);
const pageHref=options.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
const access=isVideo?{allowed:!!playbackUrl(raw.video?.url),gate:playbackUrl(raw.video?.url)?'':'unavailable',source:playbackUrl(raw.video?.url)} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview);
const modeTabs=previewSource?`<div class="channel-tabs play-mode-tabs" role="group" aria-label="播放内容"><button type="button" data-play-mode="main" aria-pressed="${mode==='main'}">正片</button><button type="button" data-play-mode="preview" aria-pressed="${mode==='preview'}">免费预告</button></div>`:'';
const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0;
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${liked}">${detailActionIcon('like')}<span>${liked?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${collected}">${detailActionIcon('save')}<span>${collected?'已收藏':'收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button><a href="/info/feedback/">${detailActionIcon('feedback')}<span>反馈</span></a></div>`;
const genres=(Array.isArray(raw.tagNames)?raw.tagNames:Array.isArray(raw.themeNames)?raw.themeNames:[]).filter(tag=>typeof tag==='string');
const useTags=Array.isArray(raw.tagNames);
const tags=genres.map((tag,i)=>{
const identity={id:(useTags?raw.tagIds:raw.themeIds)?.[i],slug:(useTags?raw.tagSlugs:raw.themeSlugs)?.[i]};
return contentSegment(identity)?`<a href="${contentPath(useTags?'tag':'topics',identity)}">${escape(tag)}</a>`:`<span>${escape(tag)}</span>`;
}).join('');
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==id).slice(0,6);
const screen=()=>`${modeTabs}<div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div><div class="play-screen-controls"><button data-play-action="next" ${!chapter||ordered.at(-1)?.id===chapter.id?'disabled':''}>下一集</button></div></div>`;
const applicationAds=promotionRow(raw.advitise?.nineGridList||home.advitise?.nineGridList);
const bannerAds=ads(raw.advitise?.detailbannerList,'play-banner-ad');
return `
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/${isVideo?'series':'shorts'}/">${isVideo?'剧集':'短剧'}</a><span>/</span>${overview||isVideo?`<span aria-current="page">${escape(displayTitle)}</span>`:`<a href="${contentPath('detail',raw)}">${escape(displayTitle)}</a><span>/</span><span aria-current="page">第${chapter?.currentEpisode||1}集</span>`}</nav>
<div class="play-detail-layout${related.length?'':' play-detail-layout--single'}"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,actions()):screen()}
${applicationAds?`<section class="play-advertisements" aria-label="推荐应用广告">${applicationAds}</section>`:''}
${overview?'':`<section class="play-detail-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${tags?`<div class="play-detail-tags"><span>分类标签:</span>${tags}</div>`:''}<div class="play-detail-meta">${!isVideo?`<span>集数:全${raw.totalEpisode||ordered.length}集</span>`:''}<span>${formatCount(raw.heatValue)}热度</span><span data-engage-count="collect" aria-live="polite">${formatCount(raw.countCollect)}收藏</span></div><p class="play-detail-description">简介:${escape(raw.desc||raw.description||'')}</p>${actions()}</section>`}
${!isVideo?`<section class="play-episodes-section"><div class="play-section-heading"><h2>剧集</h2><span>全${raw.totalEpisode||ordered.length}集</span></div><div class="play-episode-pages" role="group" aria-label="集数分段">${episodeTabs(ordered,45,initialEpisodePage)}</div><div class="play-episode-grid" id="play-episode-list">${episodeItems(id,ordered,chapter,45,initialEpisodePage,raw.slug)}</div></section>`:''}
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
${officialLinks(config)}
<section class="play-comments"><div class="play-section-heading"><h2 id="play-comments-title">评论${comments?` (${comments.totalCount||0})`:""}</h2><div class="play-sort" role="group" aria-label="评论排序"><button type="button" data-comment-sort="1" aria-pressed="${commentSort===1}">热门</button><button type="button" data-comment-sort="2" aria-pressed="${commentSort===2}">最新</button></div></div>
<form class="play-comment-form"><img class="play-compose-avatar" ${imageAttributes(user?.avatar,'/icon.svg')} alt="我的头像" width="38" height="38" decoding="async" data-lazy-eager="1"><div class="play-compose-body"><div id="reply-context"></div><textarea name="comment" aria-label="评论内容" maxlength="500" placeholder="说说你对这部剧的看法…" required></textarea><div class="play-comment-footer"><span><span id="play-comment-count">0</span> / 500</span><button type="submit">发表评论</button></div><p class="api-error" id="comment-error" role="alert"></p></div></form><div class="play-comment-list">${comments?commentListMarkup(comments,commentPage,commentSort,pageHref):empty('暂无评论')}</div>
</section>
</div>${related.length?`<aside class="play-related"><h2>热门短剧</h2><div class="play-related-list">${related.map(item=>card(item)).join('')}</div></aside>`:''}</div>`;
}
export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):string {
const pages=Math.ceil(ordered.length/size);
return pages?`<div class="episode-page-track">${Array.from({length:pages},(_,i)=>`<button type="button" data-episode-page="${i}" aria-pressed="${episodePage===i}" aria-controls="play-episode-list">${i*size+1}–${Math.min((i+1)*size,ordered.length)}</button>`).join('')}</div>`:'';
}
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string):string {
return ordered.slice(episodePage*size,(episodePage+1)*size).map((item,index)=>{
const number=item.currentEpisode||episodePage*size+index+1;
const gate=chapterListGate(item),locked=!!gate;
const content=`<span>${number}</span>${gate==='coins'?'<span class="episode-lock-corner" aria-hidden="true"><img class="coin-soft-star" src="/assets/episode-coin.svg" width="16" height="16" alt=""></span>':''}${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
if(locked)return `<button type="button" class="play-episode-locked" data-locked-chapter="${item.id}" aria-label="第 ${number} 集,${gate==='coins'?'金币解锁':'会员观看'}">${content}</button>`;
return `<a href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}" ${item.id===chapter?.id?'aria-current="true"':''} aria-label="播放第 ${number} 集">${content}</a>`;
}).join('')||empty('暂无剧集');
}
export function commentMarkup(item: Comment, child=false): string {
const children=recordList<Comment>(item.children);
const date=item.createdAt?new Date(item.createdAt):null;
const timestamp=date&&Number.isFinite(date.getTime())?`<time datetime="${date.toISOString()}">${escape(date.toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'}))}</time>`:'';
const replyName=typeof item.replyUserName==='string'?item.replyUserName.trim():'';
const reply=replyName?`<span class="play-reply-target">回复 @${escape(replyName)}</span>`:'';
const liked=item.isLike===1;
const count=typeof item.countLike==='number'&&Number.isFinite(item.countLike)?Math.max(0,Math.floor(item.countLike)):0;
const parent=typeof item.parentsId==='number'&&item.parentsId>0?item.parentsId:item.id;
return `<article class="play-comment-item${child?' play-comment-child':''}"><img class="play-comment-avatar" ${imageAttributes(item.userAvatar,COMMENT_AVATAR_FALLBACK)} alt="${escape(item.userName||'用户')}的头像" width="${child?30:40}" height="${child?30:40}" loading="lazy" decoding="async" data-lazy-eager="1"><div class="play-comment-body"><strong>${escape(item.userName||'用户')}</strong>${reply}<p>${escape(item.text)}</p><div class="play-comment-meta">${timestamp}<button type="button" data-comment-like="${item.id}" data-comment-count="${count}" aria-pressed="${liked}" aria-label="点赞 ${escape(item.userName||'用户')} 的评论"><span data-request-label>${liked?'♥':'♡'} ${formatCount(count)}</span></button><button type="button" data-reply="${item.id}" data-parent="${parent}" data-reply-name="${escape(item.userName)}">回复</button></div>${children.length?`<div class="play-comment-replies" aria-label="评论回复">${children.map(comment=>commentMarkup(comment,true)).join('')}</div>`:''}</div></article>`;
}
export function commentListMarkup(data:PageData<Comment>,page:number,sort:number,href:(page:number,sort:number)=>string):string {
return (typeof data.error==='string'?empty(data.error,true):recordList<Comment>(data.list).map(item=>commentMarkup(item)).join('')||empty('暂无评论,来说说你的看法吧'))+pagination(data.totalCount||0,page,value=>href(value,sort),10);
}