Files
web_hgdjc_h5/src/lib/client/player-markup.ts
T
2026-11-06 00:13:27 +07:00

78 lines
8.1 KiB
TypeScript

import { contentPath } from '../routing';
import type { Chapter, Config, Drama, DramaDetail, HomeData } from '../api/types';
import { recordList } from '../api/protocol';
import { ads, card, empty, escape, imageAttributes, promotionRow, title } from './render';
import { descriptionBlock, engageButtons, factsRow, hashTags, isSingleVideo, officialLinks, overviewMarkup, statusLabel } from './detail';
import { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
import { downloadUrl } from '../app-download';
import { SITE_NAME } from '../brand';
/** Recommendation shelf size (guess-like list, current item excluded). */
export const RECOMMEND_PAGE_SIZE=12;
export interface PlayerViewOptions {
chapter?:Chapter|null; isVideo?:boolean; config?:Config; home?:HomeData;
}
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));
}
const lockIcon='<svg class="film-tile-lock" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
const arrow=(direction:'prev'|'next')=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${direction==='prev'?'m15 5-7 7 7 7':'m9 5 7 7-7 7'}"/></svg>`;
/** Locked episodes are watched in the App: overlay inside the player with a download button. */
export function appGateMarkup(drama:Drama,config:Config={}):string {
const url=downloadUrl(config);
return `<div class="play-app-gate" hidden><div class="play-app-gate-copy" data-app-download-entry${url?'':' hidden'}><strong>本集请在 ${escape(SITE_NAME)} App 观看</strong><p>下载 App 即可继续观看《${escape(title(drama))}》及全部剧集</p><a class="primary play-app-gate-btn" data-app-download${url?` href="${escape(url)}"`:''} target="_blank" rel="sponsored nofollow noopener noreferrer"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App 继续观看</span></a></div><p class="play-app-gate-fallback" data-app-download-fallback${url?' hidden':''}>本集视频暂未开放,请稍后再试</p></div>`;
}
/** "推荐视频" shelf: guess-like items, current drama excluded. */
export function recommendationsMarkup(raw:DramaDetail):string {
const items=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,RECOMMEND_PAGE_SIZE);
return `<section class="play-recommend" aria-labelledby="play-recommend-title"><div class="recommend-heading"><span class="recommend-outline" aria-hidden="true">RECOMMEND</span><h2 id="play-recommend-title">推荐视频</h2><p>看了《${escape(title(raw))}》的人还在看</p></div>${items.length?`<div class="recommend-grid">${items.map(item=>card(item)).join('')}</div>`:empty('暂无推荐视频')}</section>`;
}
/** One horizontal film strip with every episode; locked tiles still link to their play page. */
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,slug?:string):string {
return ordered.map((item,index)=>{
const number=item.currentEpisode||index+1;
const locked=!!chapterListGate(item),current=item.id===chapter?.id;
return `<a class="film-tile${current?' is-current':''}${locked?' is-locked':''}" href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}"${current?' aria-current="true"':''} aria-label="第 ${number} 集${locked?',请在 App 观看':''}"><span class="film-tile-no">${String(number).padStart(2,'0')}</span>${locked?lockIcon:''}</a>`;
}).join('')||empty('暂无剧集');
}
export function episodeStrip(raw:DramaDetail,ordered:Chapter[],chapter:Chapter|null):string {
const status=statusLabel(raw);
return `<section class="play-episodes-section" aria-labelledby="play-episodes-title"><div class="play-section-heading"><h2 id="play-episodes-title">选集</h2><span>全${raw.totalEpisode||ordered.length}集${status?` · ${status}`:''}</span></div><div class="film-strip" id="play-episode-list" aria-label="剧集列表">${episodeItems(raw.id,ordered,chapter,raw.slug)}</div></section>`;
}
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
const {chapter=null,isVideo=false,config={},home={}}=options;
const overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
const single=isVideo||isSingleVideo(raw,ordered);
const displayTitle=title(raw),heading=chapter&&!single?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle;
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 position=chapter?ordered.findIndex(item=>item.id===chapter.id):-1;
const previous=!single&&position>0?ordered[position-1]:undefined,next=!single&&position>=0?ordered[position+1]:undefined;
const edge=(item:Chapter|undefined,direction:'prev'|'next')=>item?`<a class="play-edge play-edge--${direction}" href="${contentPath('play',raw,item.id)}" data-drama-switch aria-label="${direction==='prev'?'上一集':'下一集'}:第 ${item.currentEpisode||''} 集">${arrow(direction)}</a>`:'';
const screen=()=>`${modeTabs}<div class="play-stage"><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>${appGateMarkup(raw,config)}<div class="play-screen-controls"><button data-play-action="next" ${!next?'disabled':''}>下一集</button></div></div>${edge(previous,'prev')}${edge(next,'next')}</div>`;
const mark=single?'VIDEO':String(chapter?.currentEpisode||1).padStart(2,'0');
const headline=()=>`<section class="play-detail-info play-headline"><div class="play-headline-mark${single?' is-word':''}" aria-hidden="true">${mark}</div><div class="play-headline-copy"><span class="cine-kicker">${single?'视频':`第 ${chapter?.currentEpisode||1} 集 / 共 ${raw.totalEpisode||ordered.length} 集`}</span><h1 id="play-detail-title">${escape(heading)}</h1>${factsRow(raw,ordered)}${hashTags(raw)}${descriptionBlock(raw)}${engageButtons(raw)}</div></section>`;
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 play-detail-layout--single"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,ordered):screen()}
${overview?'':headline()}
${applicationAds?`<section class="play-advertisements" aria-label="推荐应用广告">${applicationAds}</section>`:''}
${!single?episodeStrip(raw,ordered,chapter):''}
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
${recommendationsMarkup(raw)}
${officialLinks(config)}
</div></div>`;
}