import { navigationIsActive } from '../site-navigation'; import { ApiClient } from "./api"; import type { Config, Drama, FooterContact, FooterInfoResponse, HomeData, PageData } from "../api/types"; import type { SiteContext } from "./context"; import { recordList, safeUrl } from "../api/protocol"; import { activeBanners, card, empty, escape, hero } from "./render"; import { configureSite, copyRoadAddress, downloadUrl } from "./config"; import { accountMenuMarkup, bindAccountUI, openAuth, renderMember, renderWallet } from "./account"; import { renderPlayer } from "./player"; import { contentPath, navigationSection, paginationPath, parseSitePath, searchPath, samePageAddress, topNavigationSection } from "../routing"; import { bindPaginationJumps, navigateTo, ROUTE_EVENT } from "./navigation"; import { startStartupDialogs, type StartupDialogs } from "./startup"; import { createDownloadQr, shouldUseDownloadDialog } from "./download"; import { processImage, startImageLoading } from "./images"; import { bindHeroSwipe } from "./hero-swipe"; import { bindPromotionMarquee } from './promo-marquee'; import { scrollToAppCategory } from "./apps"; import { renderFeedbackPage } from "./feedback"; import { positionSearchDiscovery, searchPicksMarkup, searchSuggestionsMarkup } from "./search"; import { infoContactSection } from "../info-pages"; import { buildPageHead, clientSeoEnvironment, resolveSeoEnvironment, type SeoBootstrap, type SeoContent, type SeoEnvironment } from '../seo'; import { applyPageHead } from './seo'; import type { PageBootstrap, SiteSharedData } from '../page-data'; declare global { interface Window { __dramaApiApp?: Application } } const $=(selector:string)=>document.querySelector(selector)!; export class Application implements SiteContext { api=new ApiClient(); config:Config={}; home:HomeData; signal:AbortSignal; private controller=new AbortController(); private disposePlayer?:()=>void; private toastTimer?:ReturnType; private routeKey=''; private feature=0; private renderedFeature=0; private heroSequence=0; private disposeHeroFade?:()=>void; private heroTimer?:ReturnType; private disposePromotions?:()=>void; private hot: {hotSearch?:{keyword?:string;name?:string}[];guessLike?:Drama[]}={}; private searchTimer?:ReturnType; private searchSeq=0; private initialized=false; private startupDialogs?:StartupDialogs; private footerContacts:FooterContact[]=[]; private seoEnvironment:SeoEnvironment; private seoContent:SeoContent={}; private initialSeo?:SeoBootstrap; private initialPage?:PageBootstrap; constructor(home:HomeData,private homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,private initialSession?:PageBootstrap['session'],private initialShared?:SiteSharedData){this.initialPage=page;this.home=home;this.signal=this.controller.signal;this.initialSeo=seo;this.seoEnvironment=seo?.environment||resolveSeoEnvironment({},[]);} updateSeo(content:SeoContent){this.seoContent={...this.seoContent,...content};this.syncHead();} private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));} current(){return !this.signal.aborted;} notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);} openAuth(mode='login'){openAuth(mode);} navigate(path:string){navigateTo(path);} async init(){ if(this.initialized)return;this.initialized=true; const session=this.initialSession||this.initialPage?.session;if(session)this.api.seedSession(session.user); startImageLoading(); this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this); window.addEventListener('pagehide',event=>this.hidePage(event.persisted)); window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();}); this.api.addEventListener('userchange',()=>this.updateAccount()); this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();}); await this.initializeShared(); void this.api.initializeSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message)); this.startFloatingTools(); await this.route(); } private async initializeShared(){ const shared=this.initialShared; if(shared){ this.footerContacts=recordList(shared.footer.footerInfo?.contactList); await this.applyConfig(shared.config); // SDK configuration is fetched separately and is never serialized into page HTML. if(shared.trackerConfigured)void this.api.get('config/info').then(config=>window.tracker?.configure(config)).catch(()=>{}); }else{ this.api.get('config/info').then(config=>this.applyConfig(config)).catch(error=>this.notify(error.message)); void this.loadFooter(); } } private async applyConfig(config:Config){ this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]); this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config); if(this.routeKey)this.syncHead(); window.tracker?.configure(config); this.refreshInfoContacts();await configureSite(config); } private refreshInfoContacts(){ const target=document.querySelector('#info-content [data-site-contacts]'); if(target)target.outerHTML=infoContactSection(this.config,this.footerContacts); } private disposePage(){ this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.controller.abort(); this.disposeHeroFade?.();this.disposePromotions?.();this.disposePlayer?.(); } private hidePage(persisted:boolean){ const route=parseSitePath(location.pathname,location.search); const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key); if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;} this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.(); } private restorePage(){ if(this.signal.aborted){window.location.reload();return;} this.restartHeroAutoplay(); window.tracker?.resumePage(); // Restore the document immediately, then refresh the header identity using current cookies. void this.api.revalidateSession().catch(()=>{}); } private async selectHero(index:number){ const banners=activeBanners(this.home),target=$('#hero'); if(!banners.length||!target)return; const selected=(index%banners.length+banners.length)%banners.length; if(selected===this.feature)return; this.feature=selected; const sequence=++this.heroSequence,signal=this.signal; const incoming=document.createElement('div');incoming.innerHTML=hero(banners,selected); const artwork=incoming.querySelector('.hero-art'); if(artwork){ const source=artwork.getAttribute('data-src'); if(source)await processImage(source,artwork); if(artwork.decode)await artwork.decode().catch(()=>{}); } if(sequence!==this.heroSequence||signal.aborted||!target.isConnected)return; this.disposeHeroFade?.(); const previous=target.cloneNode(true) as HTMLElement; previous.removeAttribute('id');previous.classList.add('hero-outgoing'); previous.setAttribute('aria-hidden','true');previous.inert=true; previous.querySelectorAll('[data-src]').forEach(image=>image.removeAttribute('data-src')); target.replaceChildren(...incoming.childNodes);this.renderedFeature=selected; if(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches||typeof previous.animate!=='function')return; target.append(previous); const animation=previous.animate([{opacity:1},{opacity:0}],{duration:500,easing:'ease-in-out',fill:'forwards'}); const dispose=()=>{animation.cancel();previous.remove();if(this.disposeHeroFade===dispose)this.disposeHeroFade=undefined;}; this.disposeHeroFade=dispose; void animation.finished.then(dispose,dispose); } private stopHeroAutoplay(){clearInterval(this.heroTimer);this.heroTimer=undefined;} private bindPromotions(){ this.disposePromotions?.(); const dispose=Array.from(document.querySelectorAll('[data-promotion-row]'),root=>bindPromotionMarquee(root,this.signal)); this.disposePromotions=()=>dispose.forEach(close=>close()); } private restartHeroAutoplay(){ this.stopHeroAutoplay(); if(this.signal.aborted||location.pathname!=='/'||document.hidden||activeBanners(this.home).length<2)return; this.heroTimer=setInterval(()=>this.selectHero(this.feature+1),3000); } async route(){ // Every page navigation goes through the server. Re-entry after a mutation reloads fresh HTML. if(this.routeKey){window.location.reload();return;} this.routeKey=location.pathname+location.search; const page=this.initialPage;this.initialPage=undefined; const seo=this.initialSeo;this.initialSeo=undefined; if(seo&&samePageAddress(seo.path,this.routeKey))this.seoContent=seo.content; const route=parseSitePath(location.pathname,location.search); const section=route?navigationSection(route):'',topSection=topNavigationSection(route); document.querySelectorAll('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info]').forEach(link=>{ const selected=link.dataset.siteNav?navigationIsActive(link.getAttribute('href')||'',this.routeKey,link.dataset.siteNav==='top'?'top':'sidebar'):link.dataset.hgPage?link.dataset.hgPage===topSection:link.dataset.info?route?.group==='info'&&link.dataset.info===route.key:(link.dataset.page||link.dataset.mobileRoute)===section; link.classList.toggle('active',selected); if(selected)link.setAttribute('aria-current','page');else link.removeAttribute('aria-current'); }); window.dispatchEvent(new Event(ROUTE_EVENT)); if(!route)return; this.bindPromotions(); if(!route.group){ bindHeroSwipe($('.home-content'),{enabled:()=>activeBanners(this.home).length>1,onStart:()=>this.stopHeroAutoplay(),onStep:step=>this.selectHero(this.feature+step),onEnd:()=>this.restartHeroAutoplay()},this.signal); this.restartHeroAutoplay();return; } // Error HTML stays visible too; missing initial data must never trigger a client page fetch. if(!page||!samePageAddress(page.path,this.routeKey))return; const pageHref=(value:number)=>paginationPath(this.routeKey,value); if(page.panel==='player'&&page.player){ this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})}); }else if(page.panel==='search'&&page.search){ $('#query').setAttribute('value',page.search.keyword); window.tracker?.search(page.search.keyword,page.search.total,page.search.page); }else if(page.panel==='info'){ if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true); else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true); else if(page.info?.feedback)renderFeedbackPage(this,true); } } private bindEvents(){ bindPaginationJumps(); document.addEventListener('visibilitychange',()=>this.restartHeroAutoplay()); document.addEventListener('click',event=>{void (async()=>{ const el=(event.target as Element).closest('button,a');if(!el)return; if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;} if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;} if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;} if(el.hasAttribute('data-logout')){await this.api.post('auth/logout',{});this.navigate('/');this.notify('已退出登录');return;} if(el.dataset.sort){const sort=Number(el.dataset.sort);this.navigate(paginationPath(location.pathname+location.search,1,{sort:sort===1?null:sort}));return;} if(el.dataset.appCategory){scrollToAppCategory($('#channel-content'),el.dataset.appCategory);return;} if(el.dataset.feature!==undefined||el.dataset.heroStep){this.selectHero(el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep));this.restartHeroAutoplay();return;} if(el.dataset.apiAction==='retry'){window.location.reload();return;} if(el.dataset.accountAction==='coins'){this.navigate('/info/deposit/');return;} if(el.dataset.roadCopy){await copyRoadAddress(el);return;} if(el.hasAttribute('data-app-download')){ if(!downloadUrl(this.config)){event.preventDefault();return;} if(shouldUseDownloadDialog(event)){event.preventDefault();await this.download();} return; } if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;} if(el.id==='app-download-close'){($('#app-download-dialog')).close();return;} if(el.id==='floating-history'){this.navigate('/member/history/');return;} if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;} if(el.id==='floating-app-close'){$('#page-tool-download').style.display='none';return;} if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;} if(el.id==='search-clear'){localStorage.removeItem('91-search-history');await this.searchDiscovery();return;} if(el.id==='search-picks-refresh'){const picks=await this.api.get>('playlet/all/list',{pageNum:1,pageSize:3,sortType:4});this.hot.guessLike=recordList(picks.list).slice(0,3);$('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike);return;} })().catch(error=>this.notify(error instanceof Error?error.message:'操作失败'));}); const form=$('#search'),query=$('#query'); const panel=$('#search-discovery'),position=()=>positionSearchDiscovery(form,panel); window.addEventListener('resize',position); if(typeof ResizeObserver!=='undefined'){const observer=new ResizeObserver(position);observer.observe(panel);this.signal.addEventListener('abort',()=>observer.disconnect(),{once:true});} form.addEventListener('submit',event=>{event.preventDefault();const text=query.value.trim();if(!text)return;try{const history:string[]=JSON.parse(localStorage.getItem('91-search-history')||'[]');localStorage.setItem('91-search-history',JSON.stringify([text,...history.filter(v=>v!==text)].slice(0,10)));}catch{}this.closeSearchDiscovery();this.navigate(searchPath(text));}); query.addEventListener('focus',()=>{void this.searchDiscovery();}); query.addEventListener('click',()=>{if(panel.hidden)void this.searchDiscovery();}); query.addEventListener('input',()=>{clearTimeout(this.searchTimer);this.searchTimer=setTimeout(()=>{void this.searchDiscovery();},250);}); query.addEventListener('keydown',event=>{if(event.key==='Escape')this.closeSearchDiscovery();}); document.addEventListener('click',event=>{if(!form.contains(event.target as Node))this.closeSearchDiscovery();}); } private async download(){ const url=downloadUrl(this.config);if(!url)return; const dialog=$('#app-download-dialog');if(!dialog.open)dialog.showModal(); const qr=await createDownloadQr(url); if(dialog.open&&downloadUrl(this.config)===url){const image=$('#app-platform-qr');image.src=qr;image.alt='91短剧 App 下载二维码';} } private createAccountMenu(){ const panel=document.createElement('div');panel.id='profile-popover';panel.hidden=true;panel.setAttribute('aria-label','账户菜单');document.body.append(panel); const entry=$('#account-entry');entry.addEventListener('click',()=>{this.updateAccount();const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=rect.bottom+12+'px';panel.hidden=!panel.hidden;entry.setAttribute('aria-expanded',String(!panel.hidden));}); document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node)){panel.hidden=true;entry.setAttribute('aria-expanded','false');}}); window.addEventListener('scroll',()=>{panel.hidden=true;},{passive:true}); this.updateAccount(); } private updateAccount(){ const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客'; $('#account-entry strong').textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录'; const avatar=$('#account-entry img');void processImage(safeUrl(user?.avatar)||'/icon.svg',avatar,'img','/icon.svg'); $('.coin-value').textContent=user?String(user.recharge||0):'—';$('.coin-entry').setAttribute('aria-label',user?`金币余额 ${user.recharge||0}`:'金币余额,登录后查看'); const panel=document.getElementById('profile-popover');if(!panel)return; panel.innerHTML=accountMenuMarkup(user); } private async loadDiscovery(){ try{ this.hot=await this.api.get('playlet/hot-like'); $('#popular-search-tags').innerHTML=(this.hot.hotSearch||[]).map((item,i)=>{const text=item.keyword||item.name||'';return `${escape(text)}`;}).join(''); }catch{} } private closeSearchDiscovery(){this.searchSeq++;$('#search-discovery').hidden=true;$('#query').setAttribute('aria-expanded','false');} private async searchDiscovery(){ const seq=++this.searchSeq;const query=$('#query').value.trim();const panel=$('#search-discovery');panel.hidden=false; $('#query').setAttribute('aria-expanded','true');positionSearchDiscovery($('#search'),panel); if(!Object.keys(this.hot).length)await this.loadDiscovery(); if(seq!==this.searchSeq||query!==$('#query').value.trim())return; let history:string[]=[];try{history=JSON.parse(localStorage.getItem('91-search-history')||'[]');}catch{} $('#search-history-tags').innerHTML=(Array.isArray(history)?history.filter(text=>typeof text==='string').slice(0,10):[]).map(text=>`${escape(text)}`).join('')||'暂无搜索记录'; $('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike); positionSearchDiscovery($('#search'),panel); try{ const suggestions=query?await this.api.get<{list?:unknown[];suggestions?:unknown[]}>('playlet/search/suggest',{keyword:query}):null; if(seq!==this.searchSeq||query!==$('#query').value.trim())return; const list=suggestions?.list||suggestions?.suggestions||this.hot.hotSearch||[]; $('#search-suggestion-title').textContent=query?'搜索联想':'热搜榜'; $('#search-suggestion-list').innerHTML=searchSuggestionsMarkup(list,query); }catch(error){if(seq===this.searchSeq)$('#search-suggestion-list').innerHTML=`

${escape(error instanceof Error?error.message:'搜索建议加载失败')}

`;} finally{if(seq===this.searchSeq)positionSearchDiscovery($('#search'),panel);} } private startFloatingTools(){ const tools=$('#page-tools');const visibility=()=>{const visible=window.scrollY>240;tools.classList.toggle('is-visible',visible);tools.inert=!visible;tools.setAttribute('aria-hidden',String(!visible));}; window.addEventListener('scroll',visibility,{passive:true});visibility(); } private async loadFooter(){ try{ const data=await this.api.get('config/footer/info'); const footer=data?.footerInfo;if(!footer)return; this.footerContacts=recordList(footer.contactList);this.refreshInfoContacts(); if(footer.brand?.content)$('.hg-footer-original-slogan').textContent=footer.brand.content; }catch{} } } export function startApplication(home:HomeData,homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,session?:PageBootstrap['session'],shared?:SiteSharedData){ if(window.__dramaApiApp)return; const app=new Application(home,homeLoaded,seo,page,session,shared);window.__dramaApiApp=app; void app.init().catch(error=>{app.notify(error instanceof Error?error.message:'页面初始化失败');}); }