diff --git a/deploy_prod.sh b/deploy_prod.sh index bb3cf04..ca028df 100755 --- a/deploy_prod.sh +++ b/deploy_prod.sh @@ -28,7 +28,10 @@ echo "[2/5] 打包 standalone..." echo "[3/5] 生成 tar.gz 和 SHA-256..." # 从 dist/production/ 下进入打 tar,避免外层目录名出现在压缩包里;up_next_h5_prod.sh 预期入口在根(start.mjs 等)。 # node_modules 不打进包,远端 npm ci --omit=dev 自己装 Linux 原生依赖;--no-xattrs 去 macOS 扩展属性噪音。 -(cd "$DIR/dist/production" && tar --no-xattrs --exclude='./node_modules' --exclude='.DS_Store' -czf "$STAGE/$PACKAGE_NAME" --owner=0 --group=0 .) +# 属主统一为 root:GNU tar 用 --owner/--group,macOS 自带的 bsdtar 用 --uid/--gid/--uname/--gname;COPYFILE_DISABLE 防止打入 ._* 文件。 +if tar --version 2>/dev/null | grep -q 'GNU tar'; then OWNER_FLAGS='--owner=0 --group=0'; else OWNER_FLAGS='--uid 0 --gid 0 --uname root --gname root'; fi +# shellcheck disable=SC2086 # OWNER_FLAGS 需要按空格拆成多个参数 +(cd "$DIR/dist/production" && COPYFILE_DISABLE=1 tar --no-xattrs --exclude='./node_modules' --exclude='.DS_Store' $OWNER_FLAGS -czf "$STAGE/$PACKAGE_NAME" .) (cd "$STAGE" && shasum -a 256 "$PACKAGE_NAME" > "$PACKAGE_NAME.sha256") ls -lh "$STAGE/$PACKAGE_NAME" "$STAGE/$PACKAGE_NAME.sha256" diff --git a/next-env.d.ts b/next-env.d.ts index 6936789..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next-dev/dev/types/routes.d.ts"; -import "./.next-dev/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/globals.css b/src/app/globals.css index 22489c4..c2231dd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3142,3 +3142,12 @@ html body .hg-footer-original .hg-footer-original-title .footer-brand-text{displ .hg-detail-actions .play-detail-actions{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px} @media(max-width:760px){.hg-detail-actions .play-detail-actions{width:100%}.hg-detail-actions .play-detail-actions>button{flex:1 1 0;min-width:0}.hg-detail-actions .detail-play,.hg-detail-actions .series-download:not([hidden]){flex:1 1 auto;justify-content:center}} @media(max-width:760px){#drama-play-page .hg-detail-actions .play-detail-actions{flex-wrap:nowrap}#drama-play-page .hg-detail-actions .play-detail-actions>button{flex:1 1 0;width:auto;min-width:0;max-width:none;padding:9px 6px;justify-content:center}} +.play-screen .hg-app-gate:not([hidden]){position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:24px;background:radial-gradient(circle at 50% 40%,#1d1e1ee6,#050505f2);text-align:center} +.hg-app-gate-copy{display:flex;flex-direction:column;gap:6px} +.hg-app-gate-copy strong{color:#fff;font-size:clamp(17px,2vw,22px);font-weight:700} +.hg-app-gate-copy p,.hg-app-gate-fallback{margin:0;color:#c9c9c9;font-size:14px;line-height:1.7} +.hg-app-gate-btn:not([hidden]){display:inline-flex;align-items:center;gap:8px;height:46px;padding:0 26px;border-radius:999px;font-size:15px;font-weight:700;text-decoration:none} +.hg-app-gate-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8} +.hg-app-gate:has(.hg-app-gate-btn:not([hidden])) .hg-app-gate-fallback{display:none} +.hg-app-gate:has(.hg-app-gate-btn[hidden]) .hg-app-gate-copy{display:none} +.play-screen:has(.hg-app-gate:not([hidden]))>:not(.hg-app-gate):not(video){visibility:hidden} diff --git a/src/lib/client/player-markup.ts b/src/lib/client/player-markup.ts index acb3ac1..26319f7 100644 --- a/src/lib/client/player-markup.ts +++ b/src/lib/client/player-markup.ts @@ -13,6 +13,11 @@ export function orderedChapters(value:unknown):Chapter[] { return recordList(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0)); } +/** Shown in place of the player when this episode has no playable source: invites the visitor to the App. */ +export function appGate(heading:string,visible=false):string { + return `
本集请在 黄果吃瓜 App 观看

下载 App 即可继续观看《${escape(heading)}》及全部剧集

本集视频暂未开放,请稍后再试

`; +} + /** Recommended titles replace comments: crawlable internal links with full context for each title. */ export function recommendationsMarkup(raw:DramaDetail):string { const related=recordList(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,12); @@ -25,11 +30,11 @@ export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerVie const displayTitle=title(raw),episodes=raw.totalEpisode||ordered.length,actionState=chapter||raw; const heading=chapter?`${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 previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview),blocked=!!chapter&&mode==='main'&&!access.allowed; const modeTabs=previewSource?`
`:''; const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0; const actions=()=>`
`; - const screen=()=>`${modeTabs}

正在准备视频…

`; + const screen=()=>`${modeTabs}

正在准备视频…

${appGate(heading,blocked)}
`; const applicationAds=promotionRow(raw.advitise?.nineGridList||home.advitise?.nineGridList); const bannerAds=ads(raw.advitise?.detailbannerList,'play-banner-ad'); const watchInfo=()=>`
${chapter?`正在观看 第 ${chapter.currentEpisode||1} 集 / 共 ${episodes} 集`:''}

${escape(heading)}

${detailFacts(raw,episodes,actionState)}${actions()}
${synopsisMarkup(raw,`《${displayTitle}》剧情简介`)}`; diff --git a/src/lib/client/player.ts b/src/lib/client/player.ts index e20c871..aa96c71 100644 --- a/src/lib/client/player.ts +++ b/src/lib/client/player.ts @@ -97,11 +97,12 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i page.querySelectorAll('[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('[data-media="toggle"]');if(center)center.hidden=false; - status.hidden=false; + const center=box.querySelector('[data-media="toggle"]');if(center)center.hidden=blocked; + const gate=box.querySelector('.hg-app-gate');if(gate)gate.hidden=!blocked; + status.hidden=blocked; void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:''); - if(blocked)status.textContent='本集视频暂未开放,请稍后再试'; - else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源'; + // A blocked main episode shows the App gate instead of a status line. + if(!blocked&&!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源'; else if(mode==='preview')status.textContent='免费预告 · 点击播放'; } page.querySelectorAll('button[data-play-mode]').forEach(button=>button.addEventListener('click',()=>{setMode(button.dataset.playMode as PlayMode);if(mode==='preview')void media!.toggle();},{signal})); diff --git a/tests/playback.test.mjs b/tests/playback.test.mjs index e0f20b8..d7a00d6 100644 --- a/tests/playback.test.mjs +++ b/tests/playback.test.mjs @@ -277,7 +277,7 @@ function playerFixture({allowed=false,preview=true,detail={},chapterList,mobile= mainButton.dataset.playMode='main';previewButton.dataset.playMode='preview'; const node=(parent,key)=>{if(!parent.nodes.has(key))parent.nodes.set(key,new Element());return parent.nodes.get(key);}; page.nodes.set('video',video);page.nodes.set('.play-screen',box);page.nodes.set('.play-source-status',status); - box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON')); + box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON'));box.nodes.set('.hg-app-gate',new Element()); box.nodes.set('[data-play-action="previous"]',new Element('BUTTON'));box.nodes.set('[data-play-action="next"]',new Element('BUTTON')); for(const key of ['.play-episode-pages','.play-episode-grid'])node(page,key); page.lists.set('button[data-play-mode]',preview?[mainButton,previewButton]:[]);page.lists.set('[data-play-mode]',preview?[box,mainButton,previewButton]:[box]); @@ -377,15 +377,16 @@ test('slug playback links survive client episode rendering and next controls, an }finally{dispose?.();f.dispose();} }); -test('chapters without a source offer the free preview; main shows a not-yet-available notice and never reports',async()=>{ +test('chapters without a source offer the free preview; main shows the App download gate and never reports',async()=>{ const f=playerFixture();let dispose; try{ dispose=await renderPlayer(f.app,23456,90042);await flush(); assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0); await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0); f.mainButton.dispatchEvent(new Event('click'));await flush(); - assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');assert.equal(f.status.textContent,'本集视频暂未开放,请稍后再试'); + assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');assert.equal(f.box.querySelector('.hg-app-gate').hidden,false);assert.equal(f.status.hidden,true);assert.equal(f.box.querySelector('[data-media="toggle"]').hidden,true); await f.controlActions.toggle();await f.controlActions.retry();assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.equal(f.video.plays,1); + f.previewButton.dispatchEvent(new Event('click'));await flush();assert.equal(f.box.querySelector('.hg-app-gate').hidden,true);assert.equal(f.status.hidden,false); assert.ok(!f.box.querySelector('.play-screen-overlay').innerHTML.includes('data-show-unlock')); }finally{dispose?.();f.dispose();} });