| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998 |
- <!DOCTYPE html>
- <meta name="apple-mobile-web-app-capable" content="yes" />
- <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
- <html>
- <head>
- <meta charset="UTF-8">
- <meta name="theme-color" content="#000000">
- <script src="../script/jquery.min.js"></script>
- <script src="../script/ao_module.js"></script>
- <script src="backend/common.js"></script>
- <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
- <script src="script/ass.js"></script>
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- :root {
- --accent: #0a84ff;
- --surface: #1c1c1e;
- --surface2: #2c2c2e;
- --text: #f5f5f7;
- --text-sub: #98989d;
- --transition: 0.2s ease;
- --radius: 0.4em;
- }
- html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
- #player-wrap {
- position: relative;
- width: 100%; height: 100%;
- display: flex; align-items: center; justify-content: center;
- background: #000;
- }
- #main-video {
- width: 100%; height: 100%;
- object-fit: contain; display: block; background: #000;
- }
- /* ── Controls overlay ──────────────────────────────────────────────── */
- #video-controls {
- position: absolute;
- bottom: 0; left: 0; right: 0;
- background: linear-gradient(transparent, rgba(0,0,0,0.85) 100%);
- padding: 40px 14px 12px;
- transition: opacity 0.3s;
- }
- #video-controls.hidden { opacity: 0; pointer-events: none; }
- #player-wrap:has(#video-controls.hidden) { cursor: none; }
- #progress-wrap {
- position: relative;
- height: 4px; background: rgba(255,255,255,0.25);
- border-radius: var(--radius); cursor: pointer; margin-bottom: 12px;
- }
- #progress-wrap:hover { height: 6px; }
- #progress-wrap::before {
- content: ''; position: absolute;
- top: -16px; left: 0; right: 0; height: 16px;
- }
- #progress-bar { height: 100%; border-radius: var(--radius); background: var(--accent); pointer-events: none; }
- #progress-thumb {
- position: absolute; top: 50%; transform: translateY(-50%);
- width: 14px; height: 14px;
- background: #fff; border-radius: 50%; pointer-events: none; display: none;
- }
- #progress-wrap:hover #progress-thumb { display: block; }
- #controls-row { display: flex; align-items: center; gap: 10px; }
- .ctrl-btn {
- background: none; border: none; cursor: pointer;
- color: #fff; padding: 4px; line-height: 1;
- transition: opacity var(--transition); outline: none;
- display: flex; align-items: center; justify-content: center;
- flex-shrink: 0;
- }
- .ctrl-btn:hover { opacity: 0.7; }
- .ctrl-btn img { width: 24px; height: 24px; display: block; }
- #volume-wrap { display: flex; align-items: center; gap: 8px; }
- #volume-slider {
- -webkit-appearance: none;
- width: 80px; height: 4px;
- background: rgba(255,255,255,0.3); border-radius: 4px; outline: none; cursor: pointer;
- }
- #volume-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 12px; height: 12px; background: #fff; border-radius: 50%;
- }
- /* Portrait phones/tablets drive playback volume with the hardware keys,
- so the in-page slider only eats toolbar width. Users who want it back
- can force it on from the settings popup ("Volume bar → Always show"). */
- @media (max-width: 899px) and (orientation: portrait) {
- #volume-slider { display: none; }
- }
- body.always-show-volume #volume-slider { display: block; }
- #time-display {
- font-size: 13px; color: rgba(255,255,255,0.8);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- margin-left: 4px; white-space: nowrap;
- }
- #spacer { flex: 1; }
- /* ─── ASS subtitle overlay ───────────────────────────────────────────────────── */
- /* Sized in JS to the video's displayed rect so script coordinates land correctly
- regardless of letterboxing. Children are absolutely positioned by the renderer. */
- #ass-overlay {
- display: none;
- position: absolute;
- left: 0; top: 0; width: 0; height: 0;
- pointer-events: none;
- overflow: hidden;
- /* No z-index on purpose: subtitles sit directly after <video> in the DOM,
- so they paint above the (unpositioned) video but below every later
- positioned sibling — scrub preview, controls, menus and modals all stay
- on top of the text instead of being covered by it. */
- /* libass spaces lines by the font's own ascent+descent with no extra
- leading; 1.2 would push stacked dual-language lines apart and break
- the MarginV offsets releases use to sit them one above the other. */
- line-height: 1.05;
- }
- #ass-overlay.active { display: block; }
- /* ── Scrub-bar hover preview ───────────────────────────────────────── */
- #scrub-preview {
- display: none;
- position: absolute;
- bottom: 100%;
- margin-bottom: 12px;
- transform: translateX(-50%);
- pointer-events: none;
- z-index: 8;
- flex-direction: column;
- align-items: center;
- gap: 6px;
- }
- #scrub-preview.active { display: flex; }
- #scrub-preview-thumb {
- display: none;
- border-radius: 8px;
- background-color: #000;
- background-repeat: no-repeat;
- box-shadow: 0 4px 18px rgba(0,0,0,0.8), 0 0 0 2px rgba(255,255,255,0.14);
- }
- #scrub-preview-thumb.ready { display: block; }
- #scrub-preview-time {
- background: rgba(0,0,0,0.82);
- border-radius: 6px;
- padding: 3px 9px;
- font-size: 12px; font-weight: 600;
- color: #fff; white-space: nowrap;
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- font-variant-numeric: tabular-nums;
- }
- /* ── Transcode-seek freeze frame ───────────────────────────────────── */
- /* Deliberately no z-index: both sit immediately after <video> in the DOM,
- so they paint above the (unpositioned) video but below every later
- positioned sibling — controls and menus stay on top and clickable. */
- #seek-freeze {
- display: none;
- position: absolute; inset: 0;
- width: 100%; height: 100%;
- object-fit: contain;
- background: #000;
- pointer-events: none;
- }
- #seek-spinner {
- display: none;
- position: absolute; inset: 0;
- align-items: center; justify-content: center;
- pointer-events: none;
- }
- #seek-spinner.active { display: flex; }
- .seek-spinner-box {
- display: flex; flex-direction: column;
- align-items: center; gap: 10px;
- background: rgba(0,0,0,0.55);
- backdrop-filter: blur(4px);
- -webkit-backdrop-filter: blur(4px);
- border-radius: 14px;
- padding: 18px 24px;
- font-size: 12px; color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- }
- .seek-spinner-box .spinner {
- width: 38px; height: 38px;
- border: 3px solid rgba(255,255,255,0.18);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: seekSpin 0.8s linear infinite;
- }
- @keyframes seekSpin { to { transform: rotate(360deg); } }
- /* ── Subtitle display ──────────────────────────────────────────────── */
- #subtitle-display {
- display: none;
- position: absolute;
- bottom: 72px; left: 50%; transform: translateX(-50%);
- width: 90%; text-align: center;
- pointer-events: none; /* z-index deliberately unset, see #ass-overlay */
- color: #fff; font-size: 22px; font-weight: 500; line-height: 1.45;
- text-shadow:
- -1px -1px 0 #000, 1px -1px 0 #000,
- -1px 1px 0 #000, 1px 1px 0 #000,
- 0 2px 6px rgba(0,0,0,0.85);
- }
- /* ── Centre play/pause flash indicator ─────────────────────────────── */
- #play-flash {
- position: absolute;
- top: 50%; left: 50%;
- width: 88px; height: 88px;
- margin: -44px 0 0 -44px;
- border-radius: 50%;
- background: rgba(0,0,0,0.55);
- backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
- display: flex; align-items: center; justify-content: center;
- pointer-events: none;
- opacity: 0; z-index: 18;
- }
- #play-flash img { width: 40px; height: 40px; display: block; }
- #play-flash.flash { animation: playFlash 0.62s ease-out; }
- @keyframes playFlash {
- 0% { opacity: 0; transform: scale(0.68); }
- 18% { opacity: 0.95; transform: scale(0.92); }
- 100% { opacity: 0; transform: scale(1.32); }
- }
- /* ── Resume popup ──────────────────────────────────────────────────── */
- #resume-popup {
- display: none;
- position: absolute; bottom: 90px; right: 20px; z-index: 25;
- background: rgba(22,22,24,0.97); backdrop-filter: blur(16px);
- border-radius: var(--radius); padding: 16px 18px; min-width: 240px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1);
- animation: slideUpPopup 0.2s ease;
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- }
- #resume-popup.active { display: block; }
- @keyframes slideUpPopup {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
- }
- #resume-popup-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
- #resume-popup-sub { font-size: 12px; color: var(--text-sub); margin-bottom: 12px; }
- #resume-popup-btns { display: flex; gap: 8px; }
- .resume-btn {
- flex: 1; padding: 8px; border: none; cursor: pointer;
- border-radius: calc(var(--radius) / 1.6); font-size: 12px; font-weight: 600;
- outline: none; transition: opacity var(--transition);
- }
- .resume-btn:hover { opacity: 0.8; }
- #resume-btn-continue { background: var(--accent); color: #fff; }
- #resume-btn-restart { background: var(--surface2); color: var(--text); }
- /* ── Context menu ──────────────────────────────────────────────────── */
- #player-ctx {
- display: none;
- position: absolute; z-index: 30;
- background: rgba(28,28,30,0.97);
- backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
- border-radius: var(--radius); padding: 4px 0; min-width: 192px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
- user-select: none;
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- }
- .ctx-item {
- padding: 9px 14px; font-size: 13px; cursor: pointer;
- display: flex; align-items: center; gap: 10px;
- color: var(--text); transition: background var(--transition);
- }
- .ctx-item:hover { background: rgba(255,255,255,0.08); }
- .ctx-item.ctx-active { color: var(--accent); }
- .ctx-item.ctx-disabled { opacity: 0.3; pointer-events: none; }
- .ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; }
- img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
- .ctx-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 3px 0; }
- .ctx-has-sub { justify-content: space-between; position: relative; }
- .ctx-sub-arrow { font-style: normal; opacity: 0.55; font-size: 15px; }
- #ctx-subtitle-sub {
- display: none;
- position: absolute; top: 0; left: 100%;
- background: rgba(28,28,30,0.97);
- backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
- border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
- z-index: 32;
- }
- /* ── Subtitle settings modal ───────────────────────────────────────── */
- #subtitle-settings-modal {
- display: none;
- position: absolute; top: 50%; left: 50%;
- transform: translate(-50%, -50%);
- z-index: 40;
- background: rgba(18,18,20,0.97);
- backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
- border-radius: calc(var(--radius) / 1.5); padding: 20px 22px 18px; width: 340px;
- box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- }
- #subtitle-settings-modal h3 {
- font-size: 14px; font-weight: 600; margin-bottom: 14px;
- display: flex; align-items: center; justify-content: space-between;
- }
- #sset-close {
- background: none; border: none; cursor: pointer;
- color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
- }
- #sset-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
- #sset-close:hover img { opacity: 1; }
- .sset-row {
- display: flex; align-items: center; gap: 10px;
- padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
- font-size: 12px;
- }
- .sset-row:last-child { border-bottom: none; }
- .sset-label { color: var(--text-sub); flex-shrink: 0; width: 56px; }
- .sset-seg {
- display: flex; background: var(--surface2);
- border-radius: calc(var(--radius) / 1.6); padding: 2px; gap: 2px; flex: 1;
- }
- .sset-seg-btn {
- flex: 1; padding: 4px 0; font-size: 12px; font-family: inherit;
- border: none; border-radius: calc(var(--radius) / 1.4); cursor: pointer;
- background: transparent; color: var(--text-sub);
- transition: background 0.15s, color 0.15s;
- }
- .sset-seg-btn.active { background: var(--surface); color: var(--text); }
- #sset-size { flex: 1; accent-color: var(--accent); cursor: pointer; }
- #sset-size-val { color: var(--text-sub); font-size: 11px; min-width: 32px; text-align: right; }
- #sset-font {
- flex: 1; background: var(--surface2); color: var(--text);
- border: 1px solid rgba(255,255,255,0.08); border-radius: calc(var(--radius) / 1.6);
- padding: 5px 8px; font-size: 12px; cursor: pointer; outline: none; appearance: auto;
- width: 100%;
- }
- .sset-colors { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex: 1; }
- .sset-color {
- width: 22px; height: 22px; border-radius: 50%;
- border: 2px solid transparent; outline: 2px solid transparent;
- cursor: pointer; padding: 0; flex-shrink: 0;
- transition: outline-color 0.15s, transform 0.1s;
- }
- .sset-color:hover { transform: scale(1.12); }
- .sset-color.active { outline-color: var(--text); transform: scale(1.15); }
- #sset-color-custom {
- width: 22px; height: 22px; border-radius: 50%;
- border: 2px solid rgba(255,255,255,0.2); padding: 0;
- cursor: pointer; background: transparent; flex-shrink: 0;
- }
- #sset-color-custom.active { border-color: var(--text); transform: scale(1.15); }
- #sset-preview {
- margin-top: 14px; background: rgba(0,0,0,0.65);
- border-radius: calc(var(--radius) / 1.25); padding: 14px 12px; text-align: center; line-height: 1.4;
- }
- /* ── Video info modal ──────────────────────────────────────────────── */
- #video-info-modal {
- display: none;
- position: absolute; top: 50%; left: 50%;
- transform: translate(-50%, -50%);
- z-index: 40;
- background: rgba(18,18,20,0.97);
- backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
- border-radius: calc(var(--radius) / 1.5); padding: 20px 22px 16px; width: 340px;
- box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- }
- #video-info-modal h3 {
- font-size: 14px; font-weight: 600; margin-bottom: 12px;
- display: flex; align-items: center; justify-content: space-between;
- }
- #video-info-close {
- background: none; border: none; cursor: pointer;
- color: var(--text-sub); font-size: 17px; line-height: 1; padding: 0; outline: none;
- }
- #video-info-close img { width: 14px; height: 14px; display: block; opacity: 0.55; transition: opacity var(--transition); }
- #video-info-close:hover img { opacity: 1; }
- #video-info-tabs {
- display: flex; gap: 3px;
- background: var(--surface2); border-radius: calc(var(--radius) / 1.25); padding: 3px; margin-bottom: 12px;
- }
- .info-tab {
- flex: 1; text-align: center; padding: 5px 0;
- font-size: 12px; font-weight: 500; border-radius: calc(var(--radius) / 1.6); cursor: pointer;
- color: var(--text-sub);
- transition: background var(--transition), color var(--transition);
- }
- .info-tab.active { background: var(--surface); color: var(--text); }
- .info-row {
- display: flex; gap: 8px;
- padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
- font-size: 12px; line-height: 1.4;
- }
- .info-row:last-child { border-bottom: none; }
- .info-label { color: var(--text-sub); flex-shrink: 0; width: 110px; }
- .info-value { color: var(--text); word-break: break-all; }
- /* ── Playback settings popup (gear button) ─────────────────────────── */
- #settings-popup {
- display: none;
- position: absolute;
- right: 14px; bottom: 62px;
- z-index: 35;
- background: rgba(28,28,30,0.97);
- backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
- border-radius: calc(var(--radius) * 1.5);
- padding: 6px 0; width: 236px;
- box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- user-select: none;
- }
- #settings-popup.active { display: block; }
- .set-section { padding: 6px 14px 8px; }
- .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
- .set-head {
- display: flex; align-items: center; gap: 8px;
- font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
- text-transform: uppercase; color: var(--text-sub);
- margin-bottom: 7px;
- }
- .set-head img { width: 14px; height: 14px; opacity: 0.7; display: block; }
- .set-seg {
- display: flex; background: var(--surface2);
- border-radius: calc(var(--radius) * 1.2); padding: 2px; gap: 2px;
- }
- .set-seg-btn {
- flex: 1; padding: 5px 0; font-size: 12px; font-family: inherit;
- border: none; border-radius: var(--radius); cursor: pointer;
- background: transparent; color: var(--text-sub);
- transition: background 0.15s, color 0.15s; outline: none;
- }
- .set-seg-btn:hover { color: var(--text); }
- .set-seg-btn.active { background: var(--surface); color: var(--text); }
- .speed-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
- .speed-btn {
- padding: 5px 0; font-size: 12px; font-family: inherit;
- border: none; border-radius: var(--radius); cursor: pointer;
- background: var(--surface2); color: var(--text-sub);
- transition: background 0.15s, color 0.15s; outline: none;
- }
- .speed-btn:hover { color: var(--text); }
- .speed-btn.active { background: var(--accent); color: #fff; font-weight: 600; }
- .set-link {
- display: flex; align-items: center; gap: 9px;
- padding: 8px 14px; font-size: 13px; cursor: pointer;
- color: var(--text); transition: background var(--transition);
- }
- .set-link:hover { background: rgba(255,255,255,0.08); }
- .set-link img { width: 15px; height: 15px; opacity: 0.8; display: block; }
- /* ── Toast ─────────────────────────────────────────────────────────── */
- #toast {
- position: fixed; bottom: 30px; left: 50%;
- transform: translateX(-50%) translateY(20px);
- background: rgba(30,30,30,0.95); backdrop-filter: blur(8px);
- color: var(--text); padding: 10px 20px; border-radius: 20px;
- font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- opacity: 0; transition: opacity 0.3s, transform 0.3s;
- pointer-events: none; z-index: 1000; white-space: nowrap;
- }
- #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
- </style>
- </head>
- <body>
- <div id="player-wrap">
- <video id="main-video" preload="metadata"></video>
- <!-- Frozen last frame + spinner, shown while a transcode seek re-buffers.
- Must stay directly after <video> — stacking relies on DOM order. -->
- <canvas id="seek-freeze"></canvas>
- <div id="seek-spinner">
- <div class="seek-spinner-box">
- <div class="spinner"></div>
- <span>Buffering…</span>
- </div>
- </div>
- <!-- Styled ASS/SSA subtitles are drawn here, sized to the video's
- displayed rectangle. The plain #subtitle-display below is used
- for SubRip tracks. -->
- <div id="ass-overlay"></div>
- <!-- Subtitle overlay -->
- <div id="subtitle-display"></div>
- <!-- Centre play/pause flash indicator -->
- <div id="play-flash"><img id="play-flash-icon" src="img/icons/play_white.svg" alt=""></div>
- <!-- Resume popup -->
- <div id="resume-popup">
- <div id="resume-popup-title">Resume playback?</div>
- <div id="resume-popup-sub"></div>
- <div id="resume-popup-btns">
- <button class="resume-btn" id="resume-btn-continue">Resume</button>
- <button class="resume-btn" id="resume-btn-restart">Start over</button>
- </div>
- </div>
- <!-- Context menu -->
- <div id="player-ctx">
- <div class="ctx-item" id="ctx-play"><img class="ctx-icon" src="img/icons/play_white.svg" alt="">Play</div>
- <div class="ctx-item" id="ctx-pause"><img class="ctx-icon" src="img/icons/pause_white.svg" alt="">Pause</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-repeat"><img class="ctx-icon" src="img/icons/repeat_white.svg" alt="">Repeat: Off</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item ctx-has-sub" id="ctx-subtitle-parent">
- <span>Subtitles</span>
- <i class="ctx-sub-arrow">›</i>
- <div id="ctx-subtitle-sub">
- <div class="ctx-item ctx-active" id="ctx-sub-disable"><i class="ctx-icon">✓</i>Disable</div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-sub-load"><i class="ctx-icon">+</i>Load SRT file…</div>
- </div>
- </div>
- <div class="ctx-divider"></div>
- <div class="ctx-item" id="ctx-subtitle-settings"><img class="ctx-icon" src="img/icons/settings_white.svg" alt="">Subtitle Settings</div>
- <div class="ctx-item" id="ctx-props">Video Properties</div>
- </div>
- <!-- Subtitle settings modal -->
- <div id="subtitle-settings-modal">
- <h3>Subtitle Settings<button id="sset-close" title="Close"><img src="img/icons/close_white.svg" alt=""></button></h3>
- <div class="sset-row">
- <span class="sset-label">Position</span>
- <div class="sset-seg">
- <button class="sset-seg-btn active" data-val="bottom">Bottom</button>
- <button class="sset-seg-btn" data-val="top">Top</button>
- </div>
- </div>
- <div class="sset-row">
- <span class="sset-label">Size</span>
- <input type="range" id="sset-size" min="14" max="52" step="1" value="22">
- <span id="sset-size-val">22px</span>
- </div>
- <div class="sset-row">
- <span class="sset-label">Font</span>
- <select id="sset-font">
- <option value="system-ui, sans-serif">System Default</option>
- <option value="Arial, sans-serif">Arial</option>
- <option value="'Helvetica Neue', Helvetica, sans-serif">Helvetica</option>
- <option value="Georgia, serif">Georgia</option>
- <option value="'Times New Roman', Times, serif">Times New Roman</option>
- <option value="'Courier New', Courier, monospace">Courier New</option>
- <option value="Verdana, sans-serif">Verdana</option>
- <option value="'Trebuchet MS', sans-serif">Trebuchet MS</option>
- <option value="Impact, fantasy">Impact</option>
- </select>
- </div>
- <div class="sset-row">
- <span class="sset-label">Color</span>
- <div class="sset-colors">
- <button class="sset-color active" data-color="#ffffff" style="background:#ffffff" title="White"></button>
- <button class="sset-color" data-color="#ffff00" style="background:#ffff00" title="Yellow"></button>
- <button class="sset-color" data-color="#00ffff" style="background:#00ffff" title="Cyan"></button>
- <button class="sset-color" data-color="#00ff88" style="background:#00ff88" title="Green"></button>
- <button class="sset-color" data-color="#ff8800" style="background:#ff8800" title="Orange"></button>
- <input type="color" id="sset-color-custom" value="#ffffff" title="Custom color">
- </div>
- </div>
- <div id="sset-preview">Sample subtitle text</div>
- </div>
- <!-- Video info / stats modal -->
- <div id="video-info-modal">
- <h3><span id="video-info-title">Video Properties</span><button id="video-info-close" title="Close" onclick="closeVideoInfo()"><img src="img/icons/close_white.svg" alt=""></button></h3>
- <div id="video-info-tabs">
- <div class="info-tab active" onclick="showInfoTab('props')">Properties</div>
- <div class="info-tab" onclick="showInfoTab('stats')">Stats</div>
- </div>
- <div id="video-info-body"></div>
- </div>
- <!-- Playback settings popup -->
- <div id="settings-popup">
- <div class="set-section">
- <div class="set-head"><img src="img/icons/repeat_white.svg" alt="">Repeat</div>
- <div class="set-seg" id="set-repeat-seg">
- <button class="set-seg-btn active" data-repeat="off">Off</button>
- <button class="set-seg-btn" data-repeat="one">Repeat one</button>
- </div>
- </div>
- <div class="set-section">
- <div class="set-head"><img src="img/icons/play_white.svg" alt="">Playback speed</div>
- <div class="speed-grid" id="set-speed-grid">
- <button class="speed-btn" data-speed="0.25">0.25×</button>
- <button class="speed-btn" data-speed="0.5">0.5×</button>
- <button class="speed-btn" data-speed="0.75">0.75×</button>
- <button class="speed-btn active" data-speed="1">Normal</button>
- <button class="speed-btn" data-speed="1.25">1.25×</button>
- <button class="speed-btn" data-speed="1.5">1.5×</button>
- <button class="speed-btn" data-speed="1.75">1.75×</button>
- <button class="speed-btn" data-speed="2">2×</button>
- </div>
- </div>
- <div class="set-section">
- <div class="set-head"><img src="img/icons/volume_white.svg" alt="">Volume bar</div>
- <div class="set-seg" id="set-volbar-seg">
- <button class="set-seg-btn active" data-volbar="auto">Auto</button>
- <button class="set-seg-btn" data-volbar="always">Always show</button>
- </div>
- </div>
- <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
- <div class="set-link" id="set-load-subtitle">
- <img src="img/icons/movie_white.svg" alt="">Load SRT subtitle…
- </div>
- <div class="set-link" id="set-subtitle-settings">
- <img src="img/icons/settings_white.svg" alt="">Subtitle settings
- </div>
- <div class="set-link" id="set-video-props">
- <img src="img/icons/menu_white.svg" alt="">Video properties
- </div>
- </div>
- </div>
- <!-- Player controls -->
- <div id="video-controls">
- <div id="progress-wrap">
- <div id="progress-bar" style="width:0%"></div>
- <div id="progress-thumb" style="left:0%"></div>
- <div id="scrub-preview">
- <div id="scrub-preview-thumb"></div>
- <div id="scrub-preview-time">0:00</div>
- </div>
- </div>
- <div id="controls-row">
- <button class="ctrl-btn" id="ctrl-play" title="Play / Pause (Space)">
- <img id="play-icon" src="img/icons/play_white.svg" alt="">
- </button>
- <div id="volume-wrap">
- <button class="ctrl-btn" id="ctrl-mute" title="Mute (M)">
- <img id="mute-icon" src="img/icons/volume_white.svg" alt="">
- </button>
- <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
- </div>
- <span id="time-display">0:00 / 0:00</span>
- <span id="spacer"></span>
- <button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
- <img src="img/icons/settings_white.svg" alt="">
- </button>
- <button class="ctrl-btn" id="ctrl-fs" title="Fullscreen (F)">
- <img src="img/icons/fullscreen_white.svg" alt="">
- </button>
- </div>
- </div>
- </div><!-- #player-wrap -->
- <div id="toast"></div>
- <script>
- // ── State ─────────────────────────────────────────────────────────────────────
- var vid = document.getElementById('main-video');
- var currentFile = null; // {name, filepath, ext}
- var transcodeSeekOffset = 0; // seconds already streamed before current chunk
- var isTranscodedVideo = false;
- var transcodeDuration = 0; // total duration for transcoded video (from /media/duration/)
- var pendingResumePos = 0;
- var watchSaveInterval = null;
- var controlsTimer = null;
- var repeatSingle = (localStorage.getItem('movie_repeat_one') === '1');
- var playbackSpeed = parseFloat(localStorage.getItem('movie_playback_speed') || '1') || 1;
- var alwaysShowVolumeBar = (localStorage.getItem('movie_always_volume_bar') === '1');
- var infoRefreshTimer = null;
- var currentInfoTab = 'props';
- // Subtitle state
- var loadedSubtitleFiles = []; // [{path, name, cues:[{start,end,text}]}]
- var activeSubtitleIndex = -1; // -1 = disabled
- // Subtitle settings (shared with main Movie window via localStorage)
- var subtitleSettings = { position: 'bottom', size: 22, font: 'system-ui, sans-serif', color: '#ffffff' };
- // ── Utilities ─────────────────────────────────────────────────────────────────
- function isWebPlayable(ext) {
- return ['mp4', 'webm', 'ogg'].indexOf(ext) !== -1;
- }
- function formatTime(s) {
- if (isNaN(s) || s < 0) { return '0:00'; }
- var h = Math.floor(s / 3600);
- var m = Math.floor((s % 3600) / 60);
- var sec = Math.floor(s % 60);
- var parts = [];
- if (h > 0) { parts.push(h); }
- parts.push(m);
- parts.push(sec < 10 ? '0' + sec : '' + sec);
- return parts.join(':');
- }
- function escapeHtml(str) {
- if (!str) { return ''; }
- return String(str)
- .replace(/&/g, '&').replace(/</g, '<')
- .replace(/>/g, '>').replace(/"/g, '"');
- }
- // Flash a large play/pause glyph in the centre of the video so the user gets
- // immediate feedback that their click / key press registered.
- function flashPlayState(isPlaying) {
- var $f = $('#play-flash');
- $('#play-flash-icon').attr('src',
- isPlaying ? 'img/icons/play_white.svg' : 'img/icons/pause_white.svg');
- $f.removeClass('flash');
- void $f[0].offsetWidth; // force reflow so the animation restarts
- $f.addClass('flash');
- }
- var toastTimer;
- function showToast(msg) {
- clearTimeout(toastTimer);
- $('#toast').text(msg).addClass('show');
- toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
- }
- // ── File loading ──────────────────────────────────────────────────────────────
- var files = ao_module_loadInputFiles();
- if (files && files.length > 0) {
- currentFile = {
- name: files[0].filename,
- filepath: files[0].filepath,
- ext: files[0].filename.split('.').pop().toLowerCase()
- };
- ao_module_setWindowTitle('Movie – ' + currentFile.name);
- isTranscodedVideo = !isWebPlayable(currentFile.ext);
- vid.src = isTranscodedVideo
- ? TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
- : MEDIA_API + '?file=' + encodeURIComponent(currentFile.filepath);
- vid.autoplay = true;
- vid.load();
- // Resume position check (only for videos longer than 1 hour)
- if (!isTranscodedVideo) {
- $(vid).one('loadedmetadata.resume', function () {
- if (vid.duration > 3600 && currentFile) {
- ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (data) {
- if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
- showResumePopup(data.position, vid.duration);
- }
- });
- }
- });
- } else {
- // Fetch total duration separately (transcode stream doesn't expose it)
- fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(currentFile.filepath))
- .then(function (r) { return r.json(); })
- .then(function (data) {
- if (data.duration > 0) {
- transcodeDuration = data.duration;
- if (transcodeDuration > 3600 && currentFile) {
- ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (wdata) {
- if (wdata && !wdata.error && wdata.position > 30 && wdata.position < transcodeDuration * 0.95) {
- showResumePopup(wdata.position, transcodeDuration);
- }
- });
- }
- }
- }).catch(function () {});
- }
- }
- // ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
- // The server renders one sprite sheet per file holding a downscaled frame every
- // few seconds; we fetch it once and keep it in memory, so hovering the timeline
- // costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
- // software-decoded H.265 source, which is exactly where previews matter most.
- var storyboard = null; // layout from the server + the loaded sheet URL
- var storyboardToken = 0; // invalidates in-flight loads when the file changes
- var storyboardTimer = null;
- function resetStoryboard() {
- storyboardToken++;
- storyboard = null;
- clearTimeout(storyboardTimer);
- storyboardTimer = null;
- $('#scrub-preview-thumb').removeClass('ready');
- hideScrubPreview();
- }
- // Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
- // transcode) get established before adding more load.
- function scheduleStoryboardLoad(filepath) {
- resetStoryboard();
- if (!filepath) { return; }
- var token = storyboardToken;
- storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
- }
- function loadStoryboard(filepath, token) {
- var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
- fetch(base)
- .then(function (r) { return r.json(); })
- .then(function (meta) {
- if (token !== storyboardToken) { return; } // switched file while loading
- if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
- var img = new Image();
- img.onload = function () {
- if (token !== storyboardToken) { return; }
- meta.url = img.src;
- storyboard = meta;
- };
- img.src = base + '&image=1';
- })
- .catch(function () {}); // previews are optional — stay silent on failure
- }
- // Whole-file duration, whichever playback mode is active
- function scrubTotalDuration() {
- if (isTranscodedVideo) { return transcodeDuration; }
- return vid.duration || 0;
- }
- function initScrubPreview() {
- var $wrap = $('#progress-wrap');
- $wrap.on('mousemove', function (e) {
- var total = scrubTotalDuration();
- var rect = this.getBoundingClientRect();
- if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
- var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- showScrubPreview(ratio * total, ratio, rect.width);
- });
- $wrap.on('mouseleave', hideScrubPreview);
- }
- function showScrubPreview(time, ratio, barWidth) {
- $('#scrub-preview-time').text(formatTime(time));
- var $thumb = $('#scrub-preview-thumb');
- var halfWidth;
- if (storyboard && storyboard.url) {
- var idx = Math.max(0, Math.min(storyboard.count - 1,
- Math.floor(time / storyboard.interval)));
- var col = idx % storyboard.cols;
- var row = Math.floor(idx / storyboard.cols);
- $thumb.addClass('ready').css({
- 'width': storyboard.tileWidth + 'px',
- 'height': storyboard.tileHeight + 'px',
- 'background-image': 'url("' + storyboard.url + '")',
- 'background-position': (-col * storyboard.tileWidth) + 'px '
- + (-row * storyboard.tileHeight) + 'px'
- });
- halfWidth = storyboard.tileWidth / 2;
- } else {
- // Sheet not ready (or unavailable) — the timestamp alone is still useful
- $thumb.removeClass('ready');
- halfWidth = 28;
- }
- // Keep the popup within the bar rather than letting it hang off an edge
- var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
- $('#scrub-preview').css('left', x + 'px').addClass('active');
- }
- function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
- // ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
- // Formats the browser can't decode are streamed through ffmpeg, so "seeking"
- // means restarting the transcode at a new offset. Replacing .src blanks the
- // <video> to black until the first frame of the new segment arrives, which can
- // take seconds. Freeze the last painted frame under a spinner instead, and only
- // reveal the live element once it is genuinely playing again.
- var seekFreezeTimer = null;
- function showSeekFreeze() {
- var canvas = document.getElementById('seek-freeze');
- if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
- canvas.width = vid.videoWidth;
- canvas.height = vid.videoHeight;
- try {
- canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
- $(canvas).show();
- } catch (e) {
- // No decodable frame right now (e.g. seeking again mid-buffer) —
- // leave whatever is already frozen on screen rather than flashing black.
- }
- }
- $('#seek-spinner').addClass('active');
- clearTimeout(seekFreezeTimer);
- // Safety net: a stalled transcode must never leave the overlay stuck on
- seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
- }
- function hideSeekFreeze() {
- clearTimeout(seekFreezeTimer);
- seekFreezeTimer = null;
- $('#seek-freeze').hide();
- $('#seek-spinner').removeClass('active');
- }
- // Restart the transcode stream at `seconds`, holding the last frame until the
- // new segment plays. Clamped to the known duration.
- function transcodeSeekTo(seconds) {
- if (!currentFile) { return; }
- var pos = Math.max(0, seconds);
- if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
- showSeekFreeze();
- setSubtitleSeekFloor(pos);
- transcodeSeekOffset = pos;
- vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
- + '&start=' + pos.toFixed(3);
- vid.load();
- vid.play();
- }
- // Current playback position in whole-file terms (transcode streams restart at 0)
- function effectivePlaybackTime() {
- return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
- }
- // ── Video controls ────────────────────────────────────────────────────────────
- function initVideoControls() {
- var $prog = $('#progress-bar');
- var $thumb = $('#progress-thumb');
- var $time = $('#time-display');
- // Restore saved volume
- var savedVol = parseFloat(localStorage.getItem('movie_volume'));
- if (!isNaN(savedVol) && savedVol >= 0 && savedVol <= 1) {
- vid.volume = savedVol;
- $('#volume-slider').val(savedVol);
- }
- if (localStorage.getItem('movie_muted') === '1') { vid.muted = true; }
- $('#ctrl-play').on('click', togglePlay);
- $(vid).on('click', togglePlay);
- $('#ctrl-mute').on('click', function () { vid.muted = !vid.muted; updateMuteIcon(); });
- $('#volume-slider').on('input', function () {
- vid.volume = parseFloat($(this).val());
- updateMuteIcon();
- });
- $('#ctrl-fs').on('click', toggleFullscreen);
- // Progress bar — seek-by-reload for transcoded streams
- $('#progress-wrap').on('click', function (e) {
- if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
- transcodeSeekTo((e.offsetX / $(this).width()) * transcodeDuration);
- return;
- }
- if (vid.duration) {
- vid.currentTime = (e.offsetX / $(this).width()) * vid.duration;
- }
- });
- // Progress + subtitle update on timeupdate
- $(vid).on('timeupdate', function () {
- updateSubtitleDisplay();
- if (isTranscodedVideo) {
- var displayTime = vid.currentTime + transcodeSeekOffset;
- if (transcodeDuration > 0) {
- var pct = (displayTime / transcodeDuration) * 100;
- $prog.css('width', pct + '%');
- $thumb.css('left', 'calc(' + pct + '% - 7px)');
- $time.text(formatTime(displayTime) + ' / ' + formatTime(transcodeDuration));
- } else {
- $time.text(formatTime(displayTime) + ' / --:--');
- }
- return;
- }
- if (!vid.duration) { return; }
- var pct = (vid.currentTime / vid.duration) * 100;
- $prog.css('width', pct + '%');
- $thumb.css('left', 'calc(' + pct + '% - 7px)');
- $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
- });
- $(vid).on('play', function () {
- $('#play-icon').attr('src', 'img/icons/pause_white.svg');
- if (watchSaveInterval) { clearInterval(watchSaveInterval); }
- watchSaveInterval = setInterval(function () {
- var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
- if (!vid.paused && effectiveDuration > 3600) { saveWatchPosition(); }
- }, 30000);
- });
- $(vid).on('pause', function () {
- $('#play-icon').attr('src', 'img/icons/play_white.svg');
- if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
- var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
- var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
- if (effectiveDuration > 3600 && effectiveTime > 30) { saveWatchPosition(); }
- showControls();
- });
- $(vid).on('ended', function () {
- clearWatchPosition();
- if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
- if (!repeatSingle) { return; }
- if (isTranscodedVideo && transcodeSeekOffset > 0 && currentFile) {
- // The stream started part-way in — restart it from the beginning
- transcodeSeekTo(0);
- } else {
- vid.currentTime = 0;
- vid.play();
- }
- });
- $(vid).on('volumechange', function () {
- updateMuteIcon();
- localStorage.setItem('movie_volume', vid.volume);
- localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
- });
- // Drop the transcode-seek freeze frame the moment the new segment is live.
- // 'playing' is the accurate signal; 'loadeddata' covers a seek made while
- // paused (where 'playing' never fires), and 'error' avoids a stuck overlay.
- // Any seek — native or transcode reload — re-bases the subtitle floor
- $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
- $(vid).on('playing', hideSeekFreeze);
- $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
- $(vid).on('error', hideSeekFreeze);
- // Auto-hide controls on mouse movement
- $('#player-wrap').on('mousemove touchstart', showControls);
- document.addEventListener('fullscreenchange', function () {
- if (!document.fullscreenElement) { showControls(); }
- });
- }
- function updateMuteIcon() {
- var isMuted = vid.muted || vid.volume === 0;
- $('#mute-icon').attr('src', isMuted ? 'img/icons/mute_white.svg' : 'img/icons/volume_white.svg');
- $('#volume-slider').val(vid.muted ? 0 : vid.volume);
- }
- function togglePlay() {
- $('#resume-popup').removeClass('active');
- var willPlay = vid.paused;
- if (willPlay) { vid.play(); } else { vid.pause(); }
- flashPlayState(willPlay);
- }
- function showControls() {
- $('#video-controls').removeClass('hidden');
- clearTimeout(controlsTimer);
- controlsTimer = setTimeout(function () {
- // Keep the bar up while the settings popup is open — it is anchored to it
- if (!vid.paused && !$('#settings-popup').hasClass('active')) {
- $('#video-controls').addClass('hidden');
- }
- }, 3000);
- }
- function toggleFullscreen() {
- var el = document.getElementById('player-wrap');
- if (!document.fullscreenElement) {
- (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen).call(el);
- } else {
- (document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen).call(document);
- }
- }
- // ── Watch position (resume) ───────────────────────────────────────────────────
- function saveWatchPosition() {
- if (!currentFile) { return; }
- var effectiveDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
- var effectiveTime = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
- if (!effectiveDuration || effectiveDuration < 3600 || effectiveTime < 10) { return; }
- ao_module_agirun(SCRIPT_SET_WATCHTIME, {
- filepath: currentFile.filepath,
- position: Math.floor(effectiveTime),
- duration: Math.floor(effectiveDuration)
- }, function () {}, function () {});
- }
- function clearWatchPosition() {
- if (!currentFile) { return; }
- ao_module_agirun(SCRIPT_SET_WATCHTIME,
- { filepath: currentFile.filepath, position: 0, duration: 0 },
- function () {}, function () {});
- }
- function showResumePopup(savedPos, duration) {
- vid.pause();
- pendingResumePos = savedPos;
- $('#resume-popup-sub').text(
- 'Last position: ' + formatTime(savedPos) + ' of ' + formatTime(duration)
- );
- $('#resume-popup').addClass('active');
- showControls();
- $('#resume-btn-continue').off('click').on('click', function () {
- if (isTranscodedVideo && currentFile) {
- transcodeSeekTo(pendingResumePos);
- } else {
- vid.currentTime = pendingResumePos;
- vid.play();
- }
- $('#resume-popup').removeClass('active');
- });
- $('#resume-btn-restart').off('click').on('click', function () {
- vid.play();
- $('#resume-popup').removeClass('active');
- });
- }
- // ── Context menu ──────────────────────────────────────────────────────────────
- function initContextMenu() {
- var $ctx = $('#player-ctx');
- $('#player-wrap').on('contextmenu', function (e) {
- e.preventDefault();
- if (!currentFile) { return; }
- $('#ctx-play').toggleClass('ctx-disabled', !vid.paused);
- $('#ctx-pause').toggleClass('ctx-disabled', vid.paused);
- $('#ctx-repeat')
- .toggleClass('ctx-active', repeatSingle)
- .html('<img class="ctx-icon" src="img/icons/'
- + (repeatSingle ? 'repeat_one_white.svg' : 'repeat_white.svg')
- + '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
- $('#ctx-subtitle-sub').hide();
- closeSettingsPopup();
- var rect = this.getBoundingClientRect();
- var x = e.clientX - rect.left;
- var y = e.clientY - rect.top;
- $ctx.css({ left: x, top: y, display: 'block' });
- var mw = $ctx.outerWidth(), mh = $ctx.outerHeight();
- if (x + mw > rect.width) { $ctx.css('left', Math.max(0, x - mw)); }
- if (y + mh > rect.height) { $ctx.css('top', Math.max(0, y - mh)); }
- showControls();
- });
- $(document).on('mousedown.ctx', function (e) {
- if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
- });
- $('#ctx-play').on('click', function () { vid.play(); flashPlayState(true); $ctx.hide(); });
- $('#ctx-pause').on('click', function () { vid.pause(); flashPlayState(false); $ctx.hide(); });
- $('#ctx-repeat').on('click', function () { setRepeatSingle(!repeatSingle); $ctx.hide(); });
- $('#ctx-subtitle-settings').on('click', function () { $ctx.hide(); openSubtitleSettings(); });
- $('#ctx-props').on('click', function () { $ctx.hide(); openVideoInfo('props'); });
- }
- // ── Playback settings popup (gear button) ─────────────────────────────────────
- function setAlwaysShowVolumeBar(on) {
- alwaysShowVolumeBar = !!on;
- localStorage.setItem('movie_always_volume_bar', alwaysShowVolumeBar ? '1' : '0');
- $('body').toggleClass('always-show-volume', alwaysShowVolumeBar);
- $('#set-volbar-seg .set-seg-btn').removeClass('active')
- .filter('[data-volbar="' + (alwaysShowVolumeBar ? 'always' : 'auto') + '"]').addClass('active');
- }
- function setRepeatSingle(on) {
- repeatSingle = !!on;
- vid.loop = false; // handled manually in the 'ended' handler
- localStorage.setItem('movie_repeat_one', repeatSingle ? '1' : '0');
- $('#set-repeat-seg .set-seg-btn').removeClass('active')
- .filter('[data-repeat="' + (repeatSingle ? 'one' : 'off') + '"]').addClass('active');
- }
- function setPlaybackSpeed(rate) {
- playbackSpeed = parseFloat(rate) || 1;
- vid.playbackRate = playbackSpeed;
- localStorage.setItem('movie_playback_speed', String(playbackSpeed));
- $('#set-speed-grid .speed-btn').removeClass('active')
- .filter('[data-speed="' + playbackSpeed + '"]').addClass('active');
- }
- function openSettingsPopup() {
- setRepeatSingle(repeatSingle);
- setPlaybackSpeed(playbackSpeed);
- setAlwaysShowVolumeBar(alwaysShowVolumeBar);
- $('#player-ctx').hide();
- $('#settings-popup').addClass('active');
- showControls();
- }
- function closeSettingsPopup() { $('#settings-popup').removeClass('active'); }
- function toggleSettingsPopup() {
- if ($('#settings-popup').hasClass('active')) { closeSettingsPopup(); }
- else { openSettingsPopup(); }
- }
- function initSettingsPopup() {
- $('#ctrl-settings').on('click', function (e) {
- e.stopPropagation();
- toggleSettingsPopup();
- });
- // Dismiss when clicking anywhere outside the popup or its button
- $(document).on('mousedown.setpop', function (e) {
- if (!$(e.target).closest('#settings-popup, #ctrl-settings').length) {
- closeSettingsPopup();
- }
- });
- $('#set-repeat-seg').on('click', '.set-seg-btn', function () {
- setRepeatSingle($(this).data('repeat') === 'one');
- });
- $('#set-speed-grid').on('click', '.speed-btn', function () {
- setPlaybackSpeed($(this).data('speed'));
- });
- $('#set-volbar-seg').on('click', '.set-seg-btn', function () {
- setAlwaysShowVolumeBar($(this).data('volbar') === 'always');
- });
- $('#set-load-subtitle').on('click', function () { closeSettingsPopup(); pickSubtitleFile(); });
- $('#set-subtitle-settings').on('click', function () { closeSettingsPopup(); openSubtitleSettings(); });
- $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
- // Apply persisted values to the video element
- setRepeatSingle(repeatSingle);
- setPlaybackSpeed(playbackSpeed);
- setAlwaysShowVolumeBar(alwaysShowVolumeBar);
- // A transcode seek replaces the media source, which resets playbackRate —
- // reapply the chosen speed every time new media is loaded.
- $(vid).on('loadedmetadata', function () { vid.playbackRate = playbackSpeed; });
- }
- // ── Video info / stats modal ──────────────────────────────────────────────────
- function openVideoInfo(tab) {
- currentInfoTab = tab || 'props';
- $('#video-info-modal').show();
- $('.info-tab').removeClass('active').eq(currentInfoTab === 'props' ? 0 : 1).addClass('active');
- $('#video-info-title').text(currentInfoTab === 'props' ? 'Video Properties' : 'Streaming Stats');
- renderInfoContent();
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- if (currentInfoTab === 'stats') {
- infoRefreshTimer = setInterval(renderInfoContent, 1000);
- }
- }
- function closeVideoInfo() {
- $('#video-info-modal').hide();
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- }
- function showInfoTab(tab) {
- currentInfoTab = tab;
- $('.info-tab').removeClass('active').eq(tab === 'props' ? 0 : 1).addClass('active');
- $('#video-info-title').text(tab === 'props' ? 'Video Properties' : 'Streaming Stats');
- if (infoRefreshTimer) { clearInterval(infoRefreshTimer); infoRefreshTimer = null; }
- if (tab === 'stats') { infoRefreshTimer = setInterval(renderInfoContent, 1000); }
- renderInfoContent();
- }
- function infoRow(label, value) {
- return '<div class="info-row"><span class="info-label">' + escapeHtml(String(label)) + '</span>'
- + '<span class="info-value">' + escapeHtml(String(value)) + '</span></div>';
- }
- function renderInfoContent() {
- var html = '';
- if (currentInfoTab === 'props') {
- html += infoRow('Title', currentFile ? currentFile.name : '–');
- html += infoRow('Resolution', (vid.videoWidth && vid.videoHeight)
- ? vid.videoWidth + ' × ' + vid.videoHeight : '–');
- var infoDuration = isTranscodedVideo ? transcodeDuration : vid.duration;
- var infoPosition = isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
- html += infoRow('Duration', infoDuration ? formatTime(infoDuration) : '–');
- html += infoRow('Position', infoPosition ? formatTime(infoPosition) : '–');
- html += infoRow('Playback speed', vid.playbackRate + '×');
- html += infoRow('Volume', vid.muted ? 'Muted' : Math.round(vid.volume * 100) + '%');
- if (currentFile) { html += infoRow('File path', currentFile.filepath || '–'); }
- } else {
- var rsLabels = ['No info', 'Metadata only', 'Have current data', 'Have future data', 'Enough data'];
- var nsLabels = ['Empty', 'Idle', 'Loading', 'No source'];
- html += infoRow('Ready state', rsLabels[vid.readyState] || vid.readyState);
- html += infoRow('Network state', nsLabels[vid.networkState] || vid.networkState);
- var bufEnd = 0;
- if (vid.buffered && vid.buffered.length > 0) {
- for (var i = 0; i < vid.buffered.length; i++) {
- if (vid.buffered.start(i) <= vid.currentTime + 0.1) {
- bufEnd = Math.max(bufEnd, vid.buffered.end(i));
- }
- }
- }
- html += infoRow('Buffer ahead', Math.max(0, bufEnd - vid.currentTime).toFixed(1) + 's');
- html += infoRow('Total buffered', vid.buffered.length > 0
- ? formatTime(vid.buffered.end(vid.buffered.length - 1)) : '–');
- if (vid.getVideoPlaybackQuality) {
- var q = vid.getVideoPlaybackQuality();
- html += infoRow('Frames decoded', q.totalVideoFrames || 0);
- html += infoRow('Frames dropped', q.droppedVideoFrames || 0);
- }
- html += infoRow('Stalled / ended', vid.ended ? 'Ended' : (vid.readyState < 3 ? 'Yes' : 'No'));
- }
- $('#video-info-body').html(html);
- }
- // ── Subtitle settings ─────────────────────────────────────────────────────────
- function loadSubtitleSettings() {
- try {
- var s = JSON.parse(localStorage.getItem('movie_subtitle_settings') || 'null');
- if (s) { Object.assign(subtitleSettings, s); }
- } catch (e) {}
- }
- function saveSubtitleSettings() {
- localStorage.setItem('movie_subtitle_settings', JSON.stringify(subtitleSettings));
- }
- function applySubtitleSettings() {
- var s = subtitleSettings;
- $('#subtitle-display').css({
- 'font-size': s.size + 'px',
- 'font-family': s.font,
- 'color': s.color,
- 'bottom': s.position === 'bottom' ? '72px' : 'auto',
- 'top': s.position === 'top' ? '72px' : 'auto',
- 'transform': 'translateX(-50%)'
- });
- }
- function syncSettingsPreview() {
- var s = subtitleSettings;
- $('#sset-preview').css({
- 'font-size': s.size + 'px',
- 'font-family': s.font,
- 'color': s.color,
- 'text-shadow': '-1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000, 0 2px 6px rgba(0,0,0,0.85)'
- });
- }
- function openSubtitleSettings() {
- var s = subtitleSettings;
- $('.sset-seg-btn').removeClass('active').filter('[data-val="' + s.position + '"]').addClass('active');
- $('#sset-size').val(s.size);
- $('#sset-size-val').text(s.size + 'px');
- $('#sset-font').val(s.font);
- var swatchMatch = $('.sset-color[data-color="' + s.color.toLowerCase() + '"]');
- $('.sset-color, #sset-color-custom').removeClass('active');
- if (swatchMatch.length) { swatchMatch.addClass('active'); }
- else { $('#sset-color-custom').addClass('active'); }
- $('#sset-color-custom').val(s.color);
- syncSettingsPreview();
- $('#subtitle-settings-modal').show();
- }
- function closeSubtitleSettings() { $('#subtitle-settings-modal').hide(); }
- function initSubtitleSettings() {
- loadSubtitleSettings();
- applySubtitleSettings();
- $('#sset-close').on('click', closeSubtitleSettings);
- $(document).on('click', '.sset-seg-btn', function () {
- subtitleSettings.position = $(this).data('val');
- $('.sset-seg-btn').removeClass('active');
- $(this).addClass('active');
- applySubtitleSettings();
- saveSubtitleSettings();
- });
- $('#sset-size').on('input', function () {
- subtitleSettings.size = parseInt($(this).val(), 10);
- $('#sset-size-val').text(subtitleSettings.size + 'px');
- applySubtitleSettings();
- syncSettingsPreview();
- saveSubtitleSettings();
- });
- $('#sset-font').on('change', function () {
- subtitleSettings.font = $(this).val();
- ensureEmbeddedFontLoaded(subtitleSettings.font); // no-op for built-in fonts
- applySubtitleSettings();
- syncSettingsPreview();
- saveSubtitleSettings();
- });
- $(document).on('click', '.sset-color', function () {
- subtitleSettings.color = $(this).data('color');
- $('.sset-color, #sset-color-custom').removeClass('active');
- $(this).addClass('active');
- $('#sset-color-custom').val(subtitleSettings.color);
- applySubtitleSettings();
- syncSettingsPreview();
- saveSubtitleSettings();
- });
- $('#sset-color-custom').on('input', function () {
- subtitleSettings.color = $(this).val();
- $('.sset-color').removeClass('active');
- $('#sset-color-custom').addClass('active');
- applySubtitleSettings();
- syncSettingsPreview();
- saveSubtitleSettings();
- });
- }
- // ── Subtitle loading / parsing / display ──────────────────────────────────────
- // Exposed on window so ao_module_openFileSelector can invoke it by name
- window.movieEmbOnSubtitleFile = function (raw) {
- var entry = Array.isArray(raw) ? raw[0] : raw;
- var filePath = (entry && typeof entry === 'object') ? entry.filepath : entry;
- if (!filePath || typeof filePath !== 'string') { return; }
- $.ajax({
- url: MEDIA_API + '?file=' + encodeURIComponent(filePath),
- dataType: 'text',
- success: function (content) {
- var name = filePath.replace(/\\/g, '/').split('/').pop();
- var cues = parseSrt(content);
- var existing = -1;
- loadedSubtitleFiles.forEach(function (sf, i) {
- if (sf.path === filePath) { existing = i; }
- });
- if (existing >= 0) {
- loadedSubtitleFiles[existing].cues = cues;
- activeSubtitleIndex = existing;
- } else {
- loadedSubtitleFiles.push({ path: filePath, name: name, cues: cues });
- activeSubtitleIndex = loadedSubtitleFiles.length - 1;
- }
- showToast('Subtitle loaded: ' + name);
- applyActiveSubtitle(); // a sidecar file replaces any styled track
- updateSubtitleSubmenu();
- },
- error: function () { showToast('Failed to load subtitle file'); }
- });
- };
- function parseSrtTime(t) {
- var m = t.match(/(\d+):(\d+):(\d+)[,.](\d+)/);
- if (!m) { return 0; }
- return parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000;
- }
- function parseSrt(content) {
- var cues = [];
- var blocks = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim().split(/\n\n+/);
- blocks.forEach(function (block) {
- var lines = block.split('\n');
- var tcIdx = -1;
- for (var i = 0; i < lines.length; i++) {
- if (lines[i].indexOf('-->') !== -1) { tcIdx = i; break; }
- }
- if (tcIdx < 0) { return; }
- var parts = lines[tcIdx].split('-->');
- if (parts.length < 2) { return; }
- var start = parseSrtTime(parts[0].trim());
- var end = parseSrtTime(parts[1].trim().split(' ')[0]);
- // Strip inline SRT tags like <b>, <i>, <font>, plus any ASS override
- // blocks such as {\an8} or {\pos(x,y)}. ffmpeg's ASS-to-SubRip
- // conversion leaves positioning tags like those behind, so without this
- // they would be shown to the viewer as literal text.
- var text = lines.slice(tcIdx + 1).join('\n').trim()
- .replace(/<[^>]+>/g, '')
- .replace(/\{\\[^}]*\}/g, '')
- .trim();
- if (text) { cues.push({ start: start, end: end, text: text }); }
- });
- return cues;
- }
- function updateSubtitleDisplay() {
- // Styled tracks are drawn by the ASS renderer on its own frame loop
- if (assActive) { $('#subtitle-display').hide(); return; }
- if (activeSubtitleIndex < 0 || !loadedSubtitleFiles[activeSubtitleIndex]) {
- $('#subtitle-display').hide();
- return;
- }
- var currentTime = isTranscodedVideo
- ? (vid.currentTime + transcodeSeekOffset)
- : vid.currentTime;
- var cues = loadedSubtitleFiles[activeSubtitleIndex].cues;
- var found = null;
- for (var i = 0; i < cues.length; i++) {
- if (cues[i].start < subtitleSeekFloor) { continue; } // in flight when we landed
- if (currentTime >= cues[i].start && currentTime <= cues[i].end) { found = cues[i]; break; }
- }
- if (found) {
- $('#subtitle-display').html(escapeHtml(found.text).replace(/\n/g, '<br>')).show();
- } else {
- $('#subtitle-display').hide();
- }
- }
- // ── Embedded subtitle tracks and fonts ────────────────────────────────────────
- // MKV releases usually mux their subtitles (and the fonts they were typeset in)
- // into the container rather than shipping sidecar files. The server lists them
- // and extracts on demand; tracks are converted to SubRip, which keeps the text
- // and timing but drops ASS styling, positioning and karaoke.
- var embeddedSubtitles = []; // [{index, codec, language, title, forced, textual}]
- var embeddedFonts = []; // [{index, filename, mimetype}]
- var embeddedMediaPath = null; // file the two lists above describe
- var embeddedProbeToken = 0; // invalidates in-flight probes on file change
- var embeddedFontFaces = []; // FontFace objects registered for this video
- var embeddedFontsLoaded = {}; // family name -> already fetched
- function resetEmbeddedTracks() {
- embeddedProbeToken++;
- stopAssTrack();
- embeddedSubtitles = [];
- embeddedFonts = [];
- embeddedMediaPath = null;
- // Font families are keyed by attachment ordinal, which means the same name
- // refers to a different font in the next video — drop the old faces.
- embeddedFontFaces.forEach(function (face) {
- try { document.fonts.delete(face); } catch (e) {}
- });
- embeddedFontFaces = [];
- embeddedFontsLoaded = {};
- }
- function loadEmbeddedTrackList(filepath) {
- resetEmbeddedTracks();
- if (!filepath) { return; }
- embeddedMediaPath = filepath;
- var token = embeddedProbeToken;
- fetch(SUBTITLE_API + '?file=' + encodeURIComponent(filepath))
- .then(function (r) { return r.json(); })
- .then(function (info) {
- if (token !== embeddedProbeToken) { return; }
- if (!info || info.error) { return; }
- embeddedSubtitles = info.subtitles || [];
- embeddedFonts = info.fonts || [];
- updateSubtitleSubmenu();
- refreshEmbeddedFontOptions();
- })
- .catch(function () {}); // embedded tracks are a bonus — fail quietly
- }
- function embeddedTrackLabel(track, position) {
- var label = track.title || '';
- if (!label) { label = 'Track ' + (position + 1); }
- if (track.language) { label += ' [' + track.language + ']'; }
- if (track.forced) { label += ' (forced)'; }
- if (!track.textual) { label += ' — image based'; }
- return label;
- }
- // Extraction has to scan the whole container, so it takes a few seconds on a
- // large file. Results are kept in loadedSubtitleFiles, making reselection free.
- function selectEmbeddedSubtitle(track, position) {
- if (!track.textual) {
- showToast('That track is image based and cannot be displayed');
- return;
- }
- var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
- for (var i = 0; i < loadedSubtitleFiles.length; i++) {
- if (loadedSubtitleFiles[i].path === cacheKey) {
- activeSubtitleIndex = i;
- applyActiveSubtitle();
- updateSubtitleSubmenu();
- return;
- }
- }
- // ASS/SSA is fetched in its native form so the styling survives; everything
- // else comes through as SubRip for the plain renderer.
- var styled = /^(ass|ssa)$/i.test(track.codec) && typeof ASS !== 'undefined';
- var mediaPath = embeddedMediaPath;
- var url = SUBTITLE_API + '?file=' + encodeURIComponent(mediaPath) + '&track=' + track.index
- + (styled ? '&format=ass' : '');
- showToast('Extracting subtitle track…');
- $.ajax({
- url: url,
- dataType: 'text',
- success: function (content) {
- if (mediaPath !== embeddedMediaPath) { return; } // episode changed
- if (styled) {
- var parsed = ASS.parse(content);
- if (!parsed.events.length) {
- showToast('That track contained no readable text');
- return;
- }
- loadedSubtitleFiles.push({
- path: cacheKey,
- name: embeddedTrackLabel(track, position),
- cues: [],
- assTrack: parsed
- });
- activeSubtitleIndex = loadedSubtitleFiles.length - 1;
- applyActiveSubtitle();
- updateSubtitleSubmenu();
- showToast('Subtitle loaded · ' + parsed.events.length + ' styled lines');
- return;
- }
- var cues = parseSrt(content);
- if (cues.length === 0) {
- showToast('That track contained no readable text');
- return;
- }
- loadedSubtitleFiles.push({
- path: cacheKey,
- name: embeddedTrackLabel(track, position),
- cues: cues
- });
- activeSubtitleIndex = loadedSubtitleFiles.length - 1;
- applyActiveSubtitle();
- updateSubtitleSubmenu();
- showToast('Subtitle loaded · ' + cues.length + ' lines');
- },
- error: function () { showToast('Could not extract that subtitle track'); }
- });
- }
- // Switch the display between the styled ASS renderer and the plain SubRip
- // overlay depending on what the newly selected track carries.
- function applyActiveSubtitle() {
- var entry = activeSubtitleIndex >= 0 ? loadedSubtitleFiles[activeSubtitleIndex] : null;
- if (entry && entry.assTrack) {
- startAssTrack(entry.assTrack);
- } else {
- stopAssTrack();
- updateSubtitleDisplay();
- }
- }
- // ── Subtitle seek floor ──────────────────────────────────────────────────────
- // After a seek, any line that had already begun before the landing point is
- // skipped entirely; only lines that start at or after it are shown. Seeking to
- // 1:02 with lines at 1:00-1:03 and 1:05-1:10 therefore shows nothing until 1:05.
- var subtitleSeekFloor = 0;
- function setSubtitleSeekFloor(time) {
- subtitleSeekFloor = Math.max(0, time || 0);
- if (assRenderer) { assRenderer.setSuppressBefore(subtitleSeekFloor); }
- updateSubtitleDisplay();
- }
- function resetSubtitleSeekFloor() { setSubtitleSeekFloor(0); }
- // ── Styled ASS rendering ─────────────────────────────────────────────────────
- // ASS tracks are fetched in their native form and drawn by script/ass.js, which
- // preserves per-line styling and position. That is what makes dual-language
- // releases work: the Japanese and Chinese lines are separate events shown at the
- // same time, and a single-cue renderer can only ever display one of them.
- var assRenderer = null;
- var assActive = false;
- var assFrameReq = null;
- function initAssRenderer() {
- var overlay = document.getElementById('ass-overlay');
- if (overlay && typeof ASS !== 'undefined') { assRenderer = new ASS.Renderer(overlay); }
- }
- // Match the overlay to the letterboxed picture inside the video element, so
- // script coordinates map onto what the viewer actually sees.
- function syncAssOverlayGeometry() {
- if (!assRenderer) { return; }
- var vid = document.getElementById('main-video');
- var overlay = document.getElementById('ass-overlay');
- var cw = vid.clientWidth, ch = vid.clientHeight;
- var vw = vid.videoWidth, vh = vid.videoHeight;
- // Until the decoder reports the picture size there is no way to know where
- // the letterbox bars are. Falling back to the raw element box would scale
- // the script against the black bars too and push bottom-anchored lines down
- // over the controls, so keep the last known good geometry instead.
- if (!(vw > 0 && vh > 0 && cw > 0 && ch > 0)) { return; }
- var scale = Math.min(cw / vw, ch / vh);
- var width = vw * scale;
- var height = vh * scale;
- var left = (cw - width) / 2;
- var top = (ch - height) / 2;
- if (overlay._w !== width || overlay._h !== height || overlay._l !== left || overlay._t !== top) {
- overlay._w = width; overlay._h = height; overlay._l = left; overlay._t = top;
- overlay.style.left = left + 'px';
- overlay.style.top = top + 'px';
- overlay.style.width = width + 'px';
- overlay.style.height = height + 'px';
- assRenderer.resize(width, height);
- }
- }
- function startAssTrack(track) {
- if (!assRenderer) { return; }
- stopAssTrack();
- assRenderer.setTrack(track);
- assActive = true;
- $('#ass-overlay').addClass('active');
- $('#subtitle-display').hide(); // the plain renderer must not double up
- loadAssFonts(track);
- var step = function () {
- if (!assActive) { return; }
- syncAssOverlayGeometry();
- assRenderer.setTime(effectivePlaybackTime());
- assFrameReq = requestAnimationFrame(step);
- };
- step();
- }
- function stopAssTrack() {
- assActive = false;
- if (assFrameReq) { cancelAnimationFrame(assFrameReq); assFrameReq = null; }
- if (assRenderer) { assRenderer.clear(); }
- $('#ass-overlay').removeClass('active');
- }
- // Only pull the fonts the track actually references — a release can attach a
- // dozen and use half of them.
- function loadAssFonts(track) {
- if (typeof ASS === 'undefined') { return; }
- ASS.referencedFonts(track).forEach(function (family) {
- ensureEmbeddedFontLoaded(family);
- });
- }
- // ── Embedded fonts ───────────────────────────────────────────────────────────
- // Registered under the font's own internal family name, which is what ASS styles
- // reference, and offered in Subtitle Settings for SubRip tracks. Fetched lazily:
- // a release can carry a dozen fonts and only a few are ever used.
- function embeddedFontFamily(font) {
- return font.family || ('aroz-embedded-' + font.index);
- }
- function findEmbeddedFontByFamily(family) {
- var wanted = String(family || '').replace(/^@/, '').toLowerCase();
- var found = null;
- embeddedFonts.forEach(function (f) {
- if (embeddedFontFamily(f).toLowerCase() === wanted) { found = f; }
- });
- return found;
- }
- function refreshEmbeddedFontOptions() {
- var $select = $('#sset-font');
- $select.find('optgroup.embedded-fonts').remove();
- if (embeddedFonts.length > 0) {
- var $group = $('<optgroup label="Embedded in this video" class="embedded-fonts"></optgroup>');
- embeddedFonts.forEach(function (font) {
- $group.append($('<option></option>')
- .attr('value', embeddedFontFamily(font))
- .text(font.filename || ('Font ' + (font.index + 1))));
- });
- $select.append($group);
- }
- // A font picked for a previous video may not exist in this one
- var stillOffered = $select.find('option').filter(function () {
- return this.value === subtitleSettings.font;
- }).length > 0;
- if (!stillOffered) {
- subtitleSettings.font = 'system-ui, sans-serif';
- applySubtitleSettings();
- saveSubtitleSettings();
- }
- $select.val(subtitleSettings.font);
- ensureEmbeddedFontLoaded(subtitleSettings.font);
- }
- function ensureEmbeddedFontLoaded(family) {
- if (!family || embeddedFontsLoaded[family] || !embeddedMediaPath) { return; }
- if (typeof FontFace === 'undefined' || !document.fonts) { return; }
- var meta = findEmbeddedFontByFamily(family);
- if (!meta) { return; } // a built-in font, or one this video does not carry
- embeddedFontsLoaded[family] = true; // claim it so we do not refetch in a loop
- var url = SUBTITLE_API + '?file=' + encodeURIComponent(embeddedMediaPath) + '&font=' + meta.index;
- var face = new FontFace(embeddedFontFamily(meta), 'url("' + url + '")');
- face.load().then(function (loaded) {
- document.fonts.add(loaded);
- embeddedFontFaces.push(loaded);
- applySubtitleSettings();
- syncSettingsPreview();
- if (assRenderer) { assRenderer.clear(); } // redraw with the real face
- }).catch(function () {
- embeddedFontsLoaded[family] = false;
- });
- }
- function updateSubtitleSubmenu() {
- $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
- var disabled = (activeSubtitleIndex < 0);
- $('#ctx-sub-disable')
- .toggleClass('ctx-active', disabled)
- .find('.ctx-icon').text(disabled ? '✓' : '');
- var $load = $('#ctx-sub-load');
- // Tracks muxed into the video, listed whether or not they are loaded yet
- if (embeddedSubtitles.length > 0) {
- $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
- embeddedSubtitles.forEach(function (track, position) {
- var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
- var isActive = activeSubtitleIndex >= 0 &&
- loadedSubtitleFiles[activeSubtitleIndex] &&
- loadedSubtitleFiles[activeSubtitleIndex].path === cacheKey;
- $('<div class="ctx-item ctx-sub-dynamic'
- + (isActive ? ' ctx-active' : '')
- + (track.textual ? '' : ' ctx-disabled') + '">'
- + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
- + escapeHtml(embeddedTrackLabel(track, position))
- + '</div>')
- .on('click', function () {
- selectEmbeddedSubtitle(track, position);
- $('#player-ctx').hide();
- })
- .insertBefore($load);
- });
- }
- // Sidecar files the user loaded by hand
- var external = [];
- loadedSubtitleFiles.forEach(function (sf, idx) {
- if (String(sf.path).indexOf('embedded#') !== 0) { external.push({ sf: sf, idx: idx }); }
- });
- if (external.length > 0) {
- $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
- external.forEach(function (entry) {
- var isActive = (entry.idx === activeSubtitleIndex);
- $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + entry.idx + '">'
- + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
- + escapeHtml(entry.sf.name)
- + '</div>')
- .on('click', function () {
- activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
- applyActiveSubtitle();
- updateSubtitleSubmenu();
- $('#player-ctx').hide();
- })
- .insertBefore($load);
- });
- }
- }
- function initSubtitleMenu() {
- var $ctx = $('#player-ctx');
- var $parent = $('#ctx-subtitle-parent');
- var $sub = $('#ctx-subtitle-sub');
- var subHideTimer;
- function cancelSubHide() { clearTimeout(subHideTimer); }
- function scheduleSubHide() {
- subHideTimer = setTimeout(function () { $sub.hide(); }, 120);
- }
- $parent.on('mouseenter', function () {
- cancelSubHide();
- var ctxRight = $ctx.offset().left + $ctx.outerWidth();
- var containerRight = $('#player-wrap').offset().left + $('#player-wrap').outerWidth();
- if (ctxRight + 200 > containerRight) {
- $sub.css({ left: 'auto', right: '100%' });
- } else {
- $sub.css({ left: '100%', right: 'auto' });
- }
- $sub.show();
- });
- $parent.on('mouseleave', scheduleSubHide);
- $sub.on('mouseenter', cancelSubHide);
- $sub.on('mouseleave', scheduleSubHide);
- $('#ctx-sub-disable').on('click', function () {
- activeSubtitleIndex = -1;
- stopAssTrack();
- $('#subtitle-display').hide();
- updateSubtitleSubmenu();
- $ctx.hide();
- });
- $('#ctx-sub-load').on('click', function () {
- $ctx.hide();
- pickSubtitleFile();
- });
- }
- // Open the ArozOS file selector defaulted to the folder of the playing file
- function pickSubtitleFile() {
- var startDir = 'user:/';
- if (currentFile) {
- var fp = currentFile.filepath.replace(/\\/g, '/');
- var slash = fp.lastIndexOf('/');
- if (slash > 0) { startDir = fp.substring(0, slash); }
- }
- ao_module_openFileSelector(
- window.movieEmbOnSubtitleFile,
- startDir, 'file', false,
- { fnameOverride: 'movieEmbOnSubtitleFile', extAllowed: '.srt' }
- );
- }
- // ── Keyboard shortcuts ────────────────────────────────────────────────────────
- function initKeyboard() {
- $(document).on('keydown', function (e) {
- if ($(e.target).is('input, select, textarea')) { return; }
- switch (e.key) {
- case ' ':
- case 'k':
- e.preventDefault(); togglePlay(); showControls(); break;
- case 'ArrowRight':
- e.preventDefault();
- if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
- transcodeSeekTo(effectivePlaybackTime() + 10);
- } else {
- vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10);
- }
- showControls(); break;
- case 'ArrowLeft':
- e.preventDefault();
- if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
- transcodeSeekTo(effectivePlaybackTime() - 10);
- } else {
- vid.currentTime = Math.max(0, vid.currentTime - 10);
- }
- showControls(); break;
- case 'ArrowUp':
- e.preventDefault();
- vid.volume = Math.min(1, Math.round((vid.volume + 0.1) * 10) / 10);
- showControls(); break;
- case 'ArrowDown':
- e.preventDefault();
- vid.volume = Math.max(0, Math.round((vid.volume - 0.1) * 10) / 10);
- showControls(); break;
- case 'f':
- case 'F':
- e.preventDefault(); toggleFullscreen(); break;
- case 'm':
- case 'M':
- e.preventDefault(); vid.muted = !vid.muted; break;
- case 'r':
- case 'R':
- e.preventDefault();
- setRepeatSingle(!repeatSingle);
- showToast('Repeat: ' + (repeatSingle ? 'On' : 'Off')); break;
- case 's':
- case 'S':
- e.preventDefault(); toggleSettingsPopup(); break;
- case 'Escape':
- e.preventDefault();
- $('#player-ctx').hide();
- closeSettingsPopup();
- closeVideoInfo();
- closeSubtitleSettings(); break;
- case 'MediaPlayPause':
- e.preventDefault(); togglePlay(); break;
- }
- });
- }
- // ── Init ──────────────────────────────────────────────────────────────────────
- function initMain(){
- initVideoControls();
- initContextMenu();
- initSettingsPopup();
- initScrubPreview();
- initAssRenderer();
- resetSubtitleSeekFloor(); // a freshly opened file starts unsuppressed
- if (currentFile) {
- scheduleStoryboardLoad(currentFile.filepath);
- loadEmbeddedTrackList(currentFile.filepath);
- }
- initSubtitleMenu();
- initSubtitleSettings();
- initKeyboard();
- }
- $(document).ready(function () {
- //Check if there are another instant running. If yes, replace that another instance URL
- if (ao_module_virtualDesktop){
- //If in ao_module mode, try to make this windows the only instance
- if (!ao_module_makeSingleInstance()){
- initMain();
- }
- }else{
- initMain();
- }
- });
- </script>
- </body>
- </html>
|